Skip to content

Commit a29bd7a

Browse files
committed
feat: improvied TUI, commands and added more themes
1 parent 8171c81 commit a29bd7a

17 files changed

Lines changed: 430 additions & 127 deletions

docs/HANDOFF.md

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,87 @@
11
# Development handoff
22

3-
**Last update:** 2026-08-08
3+
## 2026-08-09 TUI UX milestone
4+
5+
Implemented a responsive centered shell capped at 112 columns, edge-to-edge narrow rendering,
6+
unique slash-command Tab completion, simplified provider forms, four additional themes, and explicit
7+
keyboard semantics. OpenRouter uses its built-in endpoint and `OPENROUTER_API_KEY`; the advanced
8+
OpenAI-compatible form uses `OPENAI_API_KEY`. Neither form exposes an environment-reference field,
9+
and pasted credentials remain ephemeral and masked. Ctrl+C is an application no-op, Ctrl+X cancels
10+
an active run, and `/exit` is the normal exit path.
11+
12+
Tests cover 140- and 50-column layouts without overflow, completion without submission, provider
13+
field visibility, fixed internal credential references, theme construction, and cancellation/exit
14+
behavior.
15+
16+
### Verification
17+
18+
Focused runs first, then the full gate set, all green on Node.js `v24.14.1`:
19+
20+
```text
21+
npm test --workspace @researk/cli -- tui-state.test.ts # passed: 32 tests
22+
npm test --workspace @researk/cli -- tui-app.test.tsx # passed: 65 tests
23+
npm test --workspace @researk/cli -- tui-provider-integration.test.tsx # passed: 5 tests
24+
npm test --workspace @researk/cli -- tui-controller.test.ts theme.test.ts # passed: 27 tests
25+
npm test --workspace @researk/cli # passed: 191 in 11 files
26+
npm run clean # passed: 7 workspace packages
27+
npm run build # passed: 7 workspace packages
28+
npm run typecheck # passed: 7 workspace packages
29+
npm test # passed: 323 in 19 files
30+
npm run lint # passed: 101 files
31+
npm run format-check # passed: 101 files
32+
git diff --check # passed: no whitespace errors
33+
node packages/cli/dist/bin.js help # passed: exit 0
34+
node packages/cli/dist/bin.js version # passed: exit 0
35+
node packages/cli/dist/bin.js doctor --json # passed: exit 0, telemetry false
36+
"" | node packages/cli/dist/bin.js # passed: exit 2, non-TTY guard
37+
```
38+
39+
Per-package totals behind the 323: `@researk/cli` 191 in 11 files (was 180), `@researk/latex-renderer`
40+
97 in 3, `@researk/provider-openai-compatible` 12, `@researk/contracts` 10, `@researk/harness` 7,
41+
`@researk/provider-openrouter` 4, and `@researk/research` 2. The CLI count rose by 11 because this
42+
milestone adds completion, layout, and key-binding coverage.
43+
44+
The real Ink lifecycle was also driven through `startTui` with a TTY-shaped stream pair against the
45+
built `dist`: the alternate screen entered on mount, an idle Ctrl+C kept the app mounted without
46+
inserting a `c`, an idle Ctrl+X was also a no-op, `/pro`+Tab produced `/provider` and Enter opened
47+
the provider picker rather than submitting, `/exit` exited with code 0, the alternate screen was
48+
restored, and stderr stayed empty.
49+
50+
### Files changed
51+
52+
- `packages/cli/src/tui/commands.ts``completeSlashCommand` for unambiguous Tab completion
53+
- `packages/cli/src/tui/App.tsx` — Tab handler, Ctrl+C/Ctrl+X semantics, `terminalWidth`/`terminalHeight`
54+
test seams, centered 112-column layout
55+
- `packages/cli/src/tui/components/Composer.tsx` — placeholder documents Ctrl+X
56+
- `packages/cli/src/tui/components/Footer.tsx` — persistent footer documents Ctrl+X and `/exit`
57+
- `packages/cli/src/tui/components/Conversation.tsx` — empty-state vertical centering
58+
- `packages/cli/src/tui/overlays/InfoOverlays.tsx` — help overlay key bindings
59+
- `packages/cli/src/tui/overlays/ProviderOverlay.tsx` — simplified forms; removed the
60+
environment-reference field
61+
- `packages/cli/src/tui/controller.ts` — fixed internal references (`OPENROUTER_API_KEY`,
62+
`OPENAI_API_KEY`) and OpenRouter default base URL
63+
- `packages/cli/src/tui/theme.ts` — four additional semantic themes (nord, dracula, solarized-dark,
64+
gruvbox)
65+
- `packages/cli/src/theme.ts` — extended one-shot theme names and palettes
66+
- `packages/cli/src/tui.tsx` — comment and `exitOnCtrlC: false` intent
67+
- `packages/cli/test/tui-state.test.ts` — completion unit tests
68+
- `packages/cli/test/tui-app.test.tsx` — completion, layout, cancellation/exit, and provider form
69+
tests
70+
- `packages/cli/test/tui-controller.test.ts` — connection reference tests
71+
- `packages/cli/test/tui-provider-integration.test.tsx` — pasted-key integration tests
72+
- `packages/cli/test/theme.test.ts` — extended theme construction
73+
- `docs/HANDOFF.md`
74+
75+
### Intentional limitation: OpenAI-compatible URLs
76+
77+
There is no universal default endpoint for an OpenAI-compatible provider. The normal `/provider`
78+
flow keeps the explicit Base URL field on the advanced OpenAI-compatible form (Provider ID + Base
79+
URL + masked key) because omitting it would pretend an impossible default exists. The form itself
80+
carries the disclosure line, and `validateProviderEndpoint` still enforces HTTPS-or-loopback and
81+
rejects credentials, query strings, and fragments in that URL. OpenRouter is the only choice with a
82+
built-in default endpoint, and its form exposes only the masked key field.
83+
84+
**Last update:** 2026-08-09
485

586
## Current milestone: macOS TUI provider-integration timeouts fixed as a budget problem, not a hang
687

packages/cli/src/theme.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
export const THEME_NAMES = ["system", "dark", "light", "high-contrast", "mono"] as const;
1+
export const THEME_NAMES = [
2+
"system",
3+
"dark",
4+
"light",
5+
"high-contrast",
6+
"mono",
7+
"nord",
8+
"dracula",
9+
"solarized-dark",
10+
"gruvbox",
11+
] as const;
212

313
export type ThemeName = (typeof THEME_NAMES)[number];
414

@@ -94,4 +104,36 @@ const PALETTES: Readonly<
94104
math: "37",
95105
code: "37;2",
96106
}),
107+
nord: Object.freeze({
108+
accent: "38;5;110;1",
109+
muted: "38;5;109",
110+
heading: "38;5;255;1",
111+
error: "38;5;174;1",
112+
math: "38;5;110",
113+
code: "38;5;221",
114+
}),
115+
dracula: Object.freeze({
116+
accent: "38;5;141;1",
117+
muted: "38;5;61",
118+
heading: "38;5;231;1",
119+
error: "38;5;203;1",
120+
math: "38;5;212",
121+
code: "38;5;228",
122+
}),
123+
"solarized-dark": Object.freeze({
124+
accent: "38;5;37;1",
125+
muted: "38;5;244",
126+
heading: "38;5;230;1",
127+
error: "38;5;166;1",
128+
math: "38;5;37",
129+
code: "38;5;136",
130+
}),
131+
gruvbox: Object.freeze({
132+
accent: "38;5;108;1",
133+
muted: "38;5;245",
134+
heading: "38;5;223;1",
135+
error: "38;5;167;1",
136+
math: "38;5;142",
137+
code: "38;5;214",
138+
}),
97139
});

packages/cli/src/tui.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { createInitialState, type ProviderConnection } from "./tui/state.js";
1414
* Mounts the full-screen TUI in the terminal's alternate screen buffer.
1515
*
1616
* Ink owns entering and leaving the alternate screen, cursor visibility, and raw mode. Restoration
17-
* runs on normal exit, on Ctrl+C, and on an error, because `unmount` and `cleanup` are invoked from
17+
* runs on normal exit and on an error, because `unmount` and `cleanup` are invoked from
1818
* a `finally` block that cannot be skipped.
1919
*/
2020
export async function startTui(
@@ -57,7 +57,7 @@ export async function startTui(
5757
stdout: io.stdout as NodeJS.WriteStream,
5858
stdin: io.stdin as NodeJS.ReadStream,
5959
stderr: io.stderr as NodeJS.WriteStream,
60-
// The application handles Ctrl+C itself so it can cancel a run without exiting.
60+
// The application consumes Ctrl+C as a no-op and uses Ctrl+X for active-run cancellation.
6161
exitOnCtrlC: false,
6262
alternateScreen: true,
6363
patchConsole: true,

packages/cli/src/tui/App.tsx

Lines changed: 61 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { type ReactNode, useCallback, useEffect, useMemo, useReducer, useRef } f
44
import { VERSION } from "../help.js";
55
import { redactSecrets, safeErrorMessage, safeTerminalText } from "../safety.js";
66
import { isThemeName, type ThemeName } from "../theme.js";
7-
import { parseSlashCommand, SLASH_COMMANDS } from "./commands.js";
7+
import { completeSlashCommand, parseSlashCommand, SLASH_COMMANDS } from "./commands.js";
88
import { Composer } from "./components/Composer.js";
99
import { Conversation } from "./components/Conversation.js";
1010
import { Footer } from "./components/Footer.js";
@@ -47,6 +47,9 @@ export interface AppProps {
4747
readonly now?: () => number;
4848
/** Overrides Ink's own exit so the exit path can be observed in tests. */
4949
readonly onExit?: () => void;
50+
/** Deterministic terminal dimensions for render tests. */
51+
readonly terminalWidth?: number;
52+
readonly terminalHeight?: number;
5053
}
5154

5255
let noticeCounter = 0;
@@ -68,11 +71,12 @@ export function App(props: AppProps): ReactNode {
6871
[state.themeName, state.colorEnabled],
6972
);
7073

71-
const width = Math.max(40, stdout.columns ?? 80);
72-
const height = Math.max(10, stdout.rows ?? 24);
74+
const terminalWidth = Math.max(1, props.terminalWidth ?? stdout.columns ?? 80);
75+
const width = Math.min(112, terminalWidth);
76+
const height = Math.max(10, props.terminalHeight ?? stdout.rows ?? 24);
7377
// The conversation and any overlay share one fixed region. Both the key handler and the renderer
7478
// need this height, so it is derived once here rather than recomputed at each use.
75-
const conversationHeight = Math.max(3, height - 12);
79+
const conversationHeight = Math.max(3, height - 10);
7680

7781
const notify = useCallback(
7882
(level: "info" | "warning" | "error" | "success", message: string): void => {
@@ -437,7 +441,7 @@ export function App(props: AppProps): ReactNode {
437441
// start a second Harness run and overwrite that slot, orphaning the first run beyond the reach
438442
// of Ctrl+C. Slash commands stay available because they never start a run.
439443
if (current.runStatus !== "idle" && !trimmed.startsWith("/")) {
440-
notify("warning", "A response is still streaming. Press Ctrl+C to cancel it first.");
444+
notify("warning", "A response is still streaming. Press Ctrl+X to cancel it first.");
441445
return;
442446
}
443447
dispatch({ type: "composer/submit" });
@@ -453,14 +457,15 @@ export function App(props: AppProps): ReactNode {
453457
useInput((input, key) => {
454458
const current = stateRef.current;
455459

456-
// Ctrl+C cancels an active run and keeps the app mounted; when idle it exits.
457-
if (key.ctrl && input === "c") {
460+
// Ctrl+C belongs to the terminal and is deliberately a no-op in the app. Ctrl+X is the
461+
// explicit in-app cancellation binding; /exit is the only normal exit command.
462+
if (key.ctrl && input === "c") return;
463+
if (key.ctrl && input === "x") {
458464
if (activeRun.current !== undefined) {
459465
activeRun.current.abort();
460466
dispatch({ type: "run/status", status: "cancelling" });
461467
return;
462468
}
463-
dispatch({ type: "exit" });
464469
return;
465470
}
466471

@@ -492,6 +497,13 @@ export function App(props: AppProps): ReactNode {
492497
insertText("\n");
493498
return;
494499
}
500+
if (key.tab) {
501+
const completed = completeSlashCommand(current.composer.value, current.composer.cursor);
502+
if (completed.value !== current.composer.value) {
503+
dispatch({ type: "composer/set", value: completed.value, cursor: completed.cursor });
504+
}
505+
return;
506+
}
495507
if (key.return) {
496508
submitComposer();
497509
return;
@@ -776,43 +788,45 @@ export function App(props: AppProps): ReactNode {
776788
const overlayNode = renderOverlay();
777789

778790
return (
779-
<Box flexDirection="column" width={width} height={height}>
780-
<Header theme={theme} state={state} version={VERSION} width={width} />
781-
{overlayNode === undefined ? (
782-
<Conversation
791+
<Box flexDirection="column" width={terminalWidth} height={height} alignItems="center">
792+
<Box flexDirection="column" width={width} height={height}>
793+
<Header theme={theme} state={state} version={VERSION} width={width} />
794+
{overlayNode === undefined ? (
795+
<Conversation
796+
theme={theme}
797+
entries={state.conversation}
798+
height={conversationHeight}
799+
scrollOffset={state.scrollOffset}
800+
emptyHint={
801+
state.connection === undefined
802+
? "No provider is connected. Use /provider to connect OpenRouter or an OpenAI-compatible endpoint."
803+
: state.model === undefined
804+
? "Connected. Use /model to select a model from the live catalog."
805+
: "Ready. Type a prompt, or / for commands."
806+
}
807+
/>
808+
) : (
809+
<Box
810+
flexDirection="column"
811+
flexGrow={1}
812+
flexShrink={1}
813+
minHeight={0}
814+
height={conversationHeight}
815+
paddingX={1}
816+
overflow="hidden"
817+
>
818+
{overlayNode}
819+
</Box>
820+
)}
821+
<Notices theme={theme} notices={state.notices} width={width} />
822+
<Composer
783823
theme={theme}
784-
entries={state.conversation}
785-
height={conversationHeight}
786-
scrollOffset={state.scrollOffset}
787-
emptyHint={
788-
state.connection === undefined
789-
? "No provider is connected. Use /provider to connect OpenRouter or an OpenAI-compatible endpoint."
790-
: state.model === undefined
791-
? "Connected. Use /model to select a model from the live catalog."
792-
: "Ready. Type a prompt, or / for commands."
793-
}
824+
composer={state.composer}
825+
disabled={state.runStatus !== "idle"}
826+
width={width}
794827
/>
795-
) : (
796-
<Box
797-
flexDirection="column"
798-
flexGrow={1}
799-
flexShrink={1}
800-
minHeight={0}
801-
height={conversationHeight}
802-
paddingX={1}
803-
overflow="hidden"
804-
>
805-
{overlayNode}
806-
</Box>
807-
)}
808-
<Notices theme={theme} notices={state.notices} width={width} />
809-
<Composer
810-
theme={theme}
811-
composer={state.composer}
812-
disabled={state.runStatus !== "idle"}
813-
width={width}
814-
/>
815-
<Footer theme={theme} state={state} width={width} />
828+
<Footer theme={theme} state={state} width={width} />
829+
</Box>
816830
</Box>
817831
);
818832

@@ -883,12 +897,10 @@ export function App(props: AppProps): ReactNode {
883897

884898
/** Describes the exact external endpoint the form will contact, so network use is never implicit. */
885899
export function providerDisclosure(form: ProviderFormState): string {
886-
const raw =
887-
form.baseUrl.trim().length > 0
888-
? form.baseUrl.trim()
889-
: form.kind === "openrouter"
890-
? OPENROUTER_DEFAULT_BASE_URL
891-
: "";
900+
if (form.kind === "openrouter") {
901+
return `connecting will contact ${OPENROUTER_DEFAULT_BASE_URL} using the built-in OPENROUTER_API_KEY reference`;
902+
}
903+
const raw = form.baseUrl.trim().length > 0 ? form.baseUrl.trim() : "";
892904
if (raw.length === 0) return "a base URL is required before any request is made";
893905
try {
894906
const url = new URL(raw);

packages/cli/src/tui/commands.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,31 @@ export function discoverSlashCommands(input: string): readonly SlashCommand[] {
7676
return SLASH_COMMANDS.filter((command) => command.name.startsWith(lowered));
7777
}
7878

79+
/**
80+
* Completes only an unambiguous command prefix; Tab never submits the composer.
81+
*
82+
* The result reports the matched command so the caller (and tests) can confirm exactly which command
83+
* a completion expanded to. When zero or multiple commands match the prefix, the input is returned
84+
* unchanged and no `command` is reported, so Tab is a no-op and the discovery list stays the only
85+
* guidance.
86+
*/
87+
export function completeSlashCommand(
88+
input: string,
89+
cursor: number,
90+
): Readonly<{ value: string; cursor: number; command?: SlashCommand }> {
91+
const safeCursor = Math.min(Math.max(cursor, 0), input.length);
92+
const beforeCursor = input.slice(0, safeCursor);
93+
if (!beforeCursor.startsWith("/") || /\s/u.test(beforeCursor)) {
94+
return { value: input, cursor: safeCursor };
95+
}
96+
const matches = discoverSlashCommands(beforeCursor);
97+
if (matches.length !== 1) return { value: input, cursor: safeCursor };
98+
const command = matches[0];
99+
if (command === undefined) return { value: input, cursor: safeCursor };
100+
const value = command.name + input.slice(safeCursor);
101+
return { value, cursor: command.name.length, command };
102+
}
103+
79104
export interface ParsedSlashCommand {
80105
readonly name: string;
81106
readonly argument: string;

packages/cli/src/tui/components/Composer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export function Composer(props: {
4343
{props.composer.value.length === 0 ? (
4444
<Text {...(muted === undefined ? {} : { color: muted })}>
4545
{props.disabled
46-
? "Streaming\u2026 Ctrl+C cancels"
46+
? "Streaming\u2026 Ctrl+X cancels"
4747
: "Ask a question, or type / for commands"}
4848
</Text>
4949
) : (

packages/cli/src/tui/components/Conversation.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ export function Conversation(props: {
218218
const muted = themeColor(props.theme, "muted");
219219
if (props.entries.length === 0) {
220220
return (
221-
<Box flexDirection="column" flexGrow={1} paddingX={1}>
221+
<Box flexDirection="column" flexGrow={1} justifyContent="center" paddingX={1}>
222222
<Text {...(muted === undefined ? {} : { color: muted })}>{props.emptyHint}</Text>
223223
</Box>
224224
);

packages/cli/src/tui/components/Footer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export function Footer(props: {
6262
</Box>
6363
<Box>
6464
<Text {...(muted === undefined ? {} : { color: muted })}>
65-
{`workspace ${state.workspaceRoot}`}
65+
{`workspace ${state.workspaceRoot} \u00b7 Ctrl+X cancel \u00b7 /exit quit`}
6666
</Text>
6767
</Box>
6868
</Box>

0 commit comments

Comments
 (0)