Skip to content

Commit 29377a3

Browse files
committed
feat(cli): interactive TUI with keyboard navigation, search, and adaptive layout
Adds interactive mode to CLI browsing commands (notes list/get/relations, search, conversations, tags) with arrow key navigation, view stack routing, adaptive dual-pane/inline preview layout, and zh/en i18n support. Co-Authored-By: Rayner Zeng <1361209507@qq.com>
2 parents acfa922 + fda0595 commit 29377a3

28 files changed

Lines changed: 1807 additions & 69 deletions

package-lock.json

Lines changed: 41 additions & 64 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
"commander": "^14.0.3",
5050
"dotenv": "^16.5.0",
5151
"fastify": "^5.3.3",
52-
"ink": "^6.8.0",
52+
"ink": "^7.0.0",
5353
"p-queue": "^8.1.0",
5454
"react": "^19.2.4",
5555
"sql.js": "^1.12.0",

server/src/cli/commands/conversations.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
shouldOutputJson, outputJson,
55
printHeader, printTable, truncate,
66
} from '../formatter.js';
7+
import { isInteractive } from '../interactive.js';
8+
import { renderApp } from '../ui/renderApp.js';
79

810
export function registerConversationsCommand(program: Command) {
911
program
@@ -19,6 +21,19 @@ export function registerConversationsCommand(program: Command) {
1921
const client = new CrystalClient(globalOpts.baseUrl);
2022

2123
try {
24+
// Interactive mode
25+
if (isInteractive(globalOpts)) {
26+
await renderApp(client, {
27+
type: 'conversations',
28+
props: {
29+
source: opts.source,
30+
status: opts.status,
31+
search: opts.search,
32+
},
33+
});
34+
return;
35+
}
36+
2237
const data = await client.getConversations({
2338
source: opts.source,
2439
status: opts.status,

server/src/cli/commands/notes.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
shouldOutputJson, outputJson,
55
printHeader, printTable, printKeyValue, printError, truncate,
66
} from '../formatter.js';
7+
import { isInteractive } from '../interactive.js';
8+
import { renderApp } from '../ui/renderApp.js';
79

810
export function registerNotesCommand(program: Command) {
911
const notes = program
@@ -22,6 +24,15 @@ export function registerNotesCommand(program: Command) {
2224
const client = new CrystalClient(globalOpts.baseUrl);
2325

2426
try {
27+
// Interactive mode
28+
if (isInteractive(globalOpts)) {
29+
await renderApp(client, {
30+
type: 'notes-list',
31+
props: { tagFilter: opts.tag },
32+
});
33+
return;
34+
}
35+
2536
const page = Math.max(1, Number(opts.page));
2637
const limit = Number(opts.limit);
2738
const offset = (page - 1) * limit;
@@ -69,6 +80,15 @@ export function registerNotesCommand(program: Command) {
6980
const client = new CrystalClient(globalOpts.baseUrl);
7081

7182
try {
83+
// Interactive mode
84+
if (isInteractive(globalOpts)) {
85+
await renderApp(client, {
86+
type: 'note-detail',
87+
props: { noteId: Number(id) },
88+
});
89+
return;
90+
}
91+
7292
const note = await client.getNote(Number(id));
7393

7494
if (shouldOutputJson(globalOpts.json)) {
@@ -122,6 +142,15 @@ export function registerNotesCommand(program: Command) {
122142
const client = new CrystalClient(globalOpts.baseUrl);
123143

124144
try {
145+
// Interactive mode
146+
if (isInteractive(globalOpts)) {
147+
await renderApp(client, {
148+
type: 'relations',
149+
props: { noteId: Number(id) },
150+
});
151+
return;
152+
}
153+
125154
const relations = await client.getNoteRelations(Number(id));
126155

127156
if (shouldOutputJson(globalOpts.json)) {

server/src/cli/commands/search.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
shouldOutputJson, outputJson,
55
printHeader, printTable, printError, truncate,
66
} from '../formatter.js';
7+
import { isInteractive } from '../interactive.js';
8+
import { renderApp } from '../ui/renderApp.js';
79

810
export function registerSearchCommand(program: Command) {
911
program
@@ -15,6 +17,15 @@ export function registerSearchCommand(program: Command) {
1517
const client = new CrystalClient(globalOpts.baseUrl);
1618

1719
try {
20+
// Interactive mode
21+
if (isInteractive(globalOpts)) {
22+
await renderApp(client, {
23+
type: 'search',
24+
props: { initialQuery: query },
25+
});
26+
return;
27+
}
28+
1829
const results = await client.search(query, Number(opts.limit));
1930

2031
if (shouldOutputJson(globalOpts.json)) {

server/src/cli/commands/tags.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
shouldOutputJson, outputJson,
55
printHeader, printTable, printError,
66
} from '../formatter.js';
7+
import { isInteractive } from '../interactive.js';
8+
import { renderApp } from '../ui/renderApp.js';
79

810
export function registerTagsCommand(program: Command) {
911
program
@@ -14,6 +16,15 @@ export function registerTagsCommand(program: Command) {
1416
const client = new CrystalClient(globalOpts.baseUrl);
1517

1618
try {
19+
// Interactive mode
20+
if (isInteractive(globalOpts)) {
21+
await renderApp(client, {
22+
type: 'tags',
23+
props: {},
24+
});
25+
return;
26+
}
27+
1728
const tags = await client.listTags();
1829

1930
if (shouldOutputJson(globalOpts.json)) {

server/src/cli/formatter.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export function printKeyValue(key: string, value: string | number | boolean): vo
4646
* Get the display width of a string in terminal columns.
4747
* CJK characters occupy 2 columns; ASCII characters occupy 1.
4848
*/
49-
function displayWidth(str: string): number {
49+
export function displayWidth(str: string): number {
5050
let w = 0;
5151
for (const ch of str) {
5252
const code = ch.codePointAt(0)!;
@@ -72,12 +72,12 @@ function displayWidth(str: string): number {
7272
}
7373

7474
/** Pad string to target display width (handles CJK double-width characters). */
75-
function padEndDisplay(str: string, targetWidth: number): string {
75+
export function padEndDisplay(str: string, targetWidth: number): string {
7676
const gap = targetWidth - displayWidth(str);
7777
return gap > 0 ? str + ' '.repeat(gap) : str;
7878
}
7979

80-
function padStartDisplay(str: string, targetWidth: number): string {
80+
export function padStartDisplay(str: string, targetWidth: number): string {
8181
const gap = targetWidth - displayWidth(str);
8282
return gap > 0 ? ' '.repeat(gap) + str : str;
8383
}

server/src/cli/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ program
1515
.description('ChatCrystal — AI conversation knowledge crystallization tool')
1616
.version(pkg.version)
1717
.option('-b, --base-url <url>', 'Server base URL', 'http://localhost:3721')
18-
.option('--json', 'Force JSON output (override TTY detection)');
18+
.option('--json', 'Force JSON output (override TTY detection)')
19+
.option('--no-interactive', 'Disable interactive mode (always use plain output)');
1920

2021
// Import and register subcommands
2122
import { registerStatusCommand } from './commands/status.js';

server/src/cli/interactive.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* Determine if the current command should run in interactive (Ink) mode.
3+
* Interactive mode requires: TTY stdout, no --json flag, no --no-interactive flag.
4+
*/
5+
export function isInteractive(globalOpts: { json?: boolean; noInteractive?: boolean }): boolean {
6+
const isTTY = process.stdout.isTTY ?? false;
7+
return isTTY && !globalOpts.json && !globalOpts.noInteractive;
8+
}

0 commit comments

Comments
 (0)