diff --git a/docs/plans/opentui-composer-ux-9.md b/docs/plans/opentui-composer-ux-9.md new file mode 100644 index 00000000000..c241fdbb379 --- /dev/null +++ b/docs/plans/opentui-composer-ux-9.md @@ -0,0 +1,240 @@ +# Plan — OpenTUI composer/UX batch (9 features) + +**Branch:** `feat/opentui-native-engine` · **Engine:** `ui-opentui/` (Node 26) +**Gate:** `cd ui-opentui && PATH="$HOME/.local/share/fnm/node-versions/v26.3.0/installation/bin:$PATH" npm run check` → exit 0. + +## TL;DR + +Nine UX fixes for the native composer + clarify prompt. **8 of 9 are front-end-only** +in `ui-opentui/`; only F3 (cost) touches the Python gateway. Every backend the new +behaviour needs (`shell.exec`, `complete.path` with `@file:`/`@folder:`/fuzzy) **already +exists** — most of this is client wiring, not new RPC surface. No new core tools, no new +`HERMES_*` env vars, no prompt-cache impact (composer/prompt are client-render only). + +| # | Symptom | Fix site | Backend | +|---|---|---|---| +| F1 | bare `/` opens the modal | `logic/slash.ts:115` `planCompletion` | none | +| F2 | `/abs/path` text triggers slash | `logic/slash.ts:115` + `logic/skillMatch.ts` | none | +| F3 | cost wrong / shows for non-Nous | `tui_gateway/server.py` + `agent/usage_pricing.py` | gateway | +| F4 | can't paste until composer focused | `view/composer.tsx` onPaste/focus | none | +| F5 | clarify ugly (no wrap, weak diff, "Other" is a row) | `view/prompts/clarifyPrompt.tsx` rewrite | none | +| F6 | clarify arrows scroll the transcript | same rewrite (preventDefault) | none | +| F7 | slash highlight/menu dies after line 1 | `logic/slash.ts:114` | none | +| F8 | file mention dies after line 1 | `logic/slash.ts:114` | none | +| F8b | `@` should be the ONLY file-mention trigger | `logic/slash.ts:93` `isPathLike` | none | +| F9 | `!cmd` → run bash, show result | `entry/main.tsx` submit + new system render | uses existing `shell.exec` | + +--- + +## F1 + F2 + F7 + F8 + F8b — the completion trigger (`logic/slash.ts`) + +All five live in one ~10-line function, `planCompletion` (slash.ts:113-121). Current: + +```ts +export function planCompletion(text: string): CompletionPlan | null { + if (text.includes('\n')) return null // ← F7/F8 die here + if (text.startsWith('/')) return { from: 0, method: 'complete.slash', params: { text } } // ← F1/F2 + const word = /(\S+)$/.exec(text)?.[1] + if (word && isPathLike(word)) { ... complete.path ... } // ← F8b: too many triggers + return null +} +``` + +### F1/F2 — slash only for a real command token +- A bare `/` (no char yet) must **not** query. Require `/` + at least one name char. +- A `/abs/path` (slash followed by a path with more `/`) is **not** a command — it's + text. The slash menu should only fire when the FIRST token matches the command + grammar (`/[A-Za-z0-9][\w.-]*` — the `NAME_RE` already in skillMatch.ts:51, which + excludes `/`). `/usr/bin` fails NAME_RE → no slash menu. +- Concretely: replace `text.startsWith('/')` with: the text starts with `/`, and the + first whitespace-delimited token after the `/` is non-empty AND matches `NAME_RE` + (i.e. `/m`, `/model foo` → yes; `/`, `/usr/bin`, `/./x` → no). Reuse `slashTokens` + /`NAME_RE` from skillMatch.ts so the trigger and the highlighter share one grammar. + +### F7/F8 — completion must survive newlines (shift+enter) +- `if (text.includes('\n')) return null` is the bug. It was a blunt guard so a multi-line + paste wouldn't spam path-completion. The right rule operates on the **current line / + current token at the cursor**, not the whole buffer. +- The composer passes the full `plainText` to `onType`. We don't currently pass the + cursor offset. **Decision D1 (below):** either (a) thread the cursor offset into + `onType` and complete the token under the cursor, or (b) cheap interim — slice to the + **last line** (`text.slice(text.lastIndexOf('\n')+1)`) and run the existing logic on + that. (a) is correct (mid-buffer edits), (b) is 1 line and covers the reported case + (typing at the end on line N). Recommend (a) for correctness; it also future-proofs + @-mention mid-line. +- Slash *highlighting* (skillMatch.ts `slashTokens`) **already scans multi-line text + correctly** (it iterates the whole string, newline-aware via `nativeCharOffset`). So + F7's "highlighting stopped" is really the same `planCompletion` newline bail starving + the menu; the highlight token itself still styles. Verify in the live smoke. + +### F8b — `@` is the only mention trigger +- `isPathLike` (slash.ts:93) currently returns true for `@`, `~`, `./`, `../`, `/`, or + any word containing `/`. The user wants **`@`-only** (drop `~`/`./`/bare paths as + mention triggers). Narrow it to `word.startsWith('@')`. +- The gateway `complete.path` (server.py:8543) already special-cases `@` richly + (`@file:`, `@folder:`, `@diff`, `@staged`, `@url:`, `@git:`, fuzzy basename search). + Its `~`/`./` branches become dead trigger paths from this TUI — leave the gateway code + (Ink still uses the path forms; it's shared) but stop emitting those queries from + ui-opentui. **No gateway change.** +- Net: typing `@` (even bare) opens the mention menu via the `@`-bare branch at + server.py:8555. Picking splices `@file:rel/path` etc. (existing accept path, + `completionFrom` honoured). + +**Tests:** extend `test/slash.test.ts` — `planCompletion('/')` → null; `planCompletion('/usr/bin')` +→ null; `planCompletion('/model')` → complete.slash; multi-line `"a\n/mod"` → complete.slash +on the trailing token; `"~/foo"` / `"./x"` → null (no longer path-like); `"@foo"` → complete.path. +Keep them as behaviour assertions, not snapshots. + +--- + +## F3 — cost: Nous-portal headers only (`tui_gateway` + `agent/usage_pricing.py`) + +**Current:** `_get_usage` (server.py:2157-2167) sets `cost_usd` from +`real_session_cost_usd(agent)` (usage_pricing.py:887), which sums **two** provider-reported +sources: +1. `agent.session_actual_cost_usd` — OpenRouter `usage.cost` accumulator. +2. `agent.get_credits_spent_micros()` — Nous `x-nous-credits-*` header delta. + +The TUI already **hides** the cost segment when `cost_usd` is absent (statusBar.tsx:241-243, +`costText` returns '' when `costUsd === undefined`) — so this is purely "which sources count." + +**User's intent (F3):** cost should come **only from the Nous portal headers**; suppress it +for every other route (cache-token pricing is unreliable across the model long tail). + +**Change:** make the OpenRouter accumulator source conditional on the route being Nous, OR +drop source #1 entirely so only the header delta (source #2) feeds `cost_usd`. Source #2 is +intrinsically Nous-only (the header only exists on Nous-portal responses), so dropping #1 +achieves "Nous-header-only" with one edit. + +> **DECISION D2 (needs glitch's confirm):** Drop OpenRouter's `session_actual_cost_usd` +> source from `real_session_cost_usd`? Trade-off: OpenRouter's `usage.cost` is itself +> *provider-reported* (the real charged number, not a Hermes estimate), so OR users lose an +> accurate readout. But it removes the cache-token guesswork the user is worried about and +> matches "only via the headers when using nous portal" literally. +> **Recommended default (implementing unless told otherwise):** gate source #1 so it only +> contributes when the active route is the Nous portal (base_url == nous inference api), +> else it's dropped. This keeps the segment Nous-only AND avoids touching shared OR/CLI +> behaviour for the `/usage` page. If even Nous-route OR-accumulator is unwanted, collapse +> to header-only. + +**Scope guard:** `real_session_cost_usd` is also consumed by `/usage` page rendering +(server.py:2237) and DB usage totals. Prefer a NEW, status-bar-specific helper +(e.g. `nous_header_cost_usd(agent)`) wired only into `_get_usage`'s `cost_usd`, leaving the +`/usage` accounting page untouched — so we don't regress the full cost report. Confirm with +the gate + a gateway unit test (`tui_gateway` tests) that a non-Nous session yields no +`cost_usd`. + +--- + +## F4 — paste while composer unfocused (`view/composer.tsx`) + +**Current:** the global keyboard handler reclaims focus on a *printable keystroke* +(`isPrintableKey`, composer.tsx:415-417). A **bracketed-paste event is not a keystroke** — +it arrives at `onPaste` only if the textarea is focused, so an unfocused composer drops it; +the user has to click/type first. + +**Fix:** the renderer delivers paste through the focused renderable. Two options: +- (a) Keep focus on the composer more aggressively (opencode keeps the prompt focused via a + reactive effect). Risky — fights transcript scroll focus. +- (b) **Recommended:** handle paste at the renderer/global level. Check whether OpenTUI + exposes a global paste hook (`renderer.on('paste')` or a keyboard event with + `key.name === 'paste'` / a paste event type). If a global paste signal exists, on paste: + `ta.focus()` then route the bytes into the existing `onPaste` logic (image / placeholder / + insert). **Must verify the API in the `opentui` skill before coding** (skill_view + references/docs). If only the focused-renderable paste exists, fall back to (a) scoped: + refocus the composer whenever no overlay/prompt is open and focus drifted (a + `createEffect` watching focus + `store.state.prompt`/overlay state). + +**Verify in live smoke** (tmux + tmux-pane-screenshot): scroll the transcript to drop focus, +then paste — text must land without a prior click. + +--- + +## F5 + F6 — clarify prompt rewrite (`view/prompts/clarifyPrompt.tsx`) + +Screenshot `/tmp/screenshots/SCR-20260613-iznq.png` confirms: long options run off the right +edge (no wrap), options differ only by `▶`/`—` glyphs (no numbers, weak), and "✎ Other…" is +a `` over `[...choices, {Other}]` (clarifyPrompt.tsx:61-75). +Native `` with a **custom keyboard-driven list** (a `For` over options + + a `selected` signal + `useKeyboard` with `key.preventDefault()` on up/down/enter — same + pattern the composer's `routeMenuKey` uses; F6 fixed by preventDefault so arrows never + reach the scrollbox). +- **Wrapping (F5):** render each option as a `` that wraps to the box width (no fixed + single-line). Indent continuation lines under the option label. Confirm `` soft-wrap + behaviour in the opentui skill (it wraps by default within a flex box of bounded width). +- **Differentiation (F5):** number every option `1.` `2.` … (digit hotkeys optional, nice-to- + have), and give the selected row the themed `selectionBg` + accent fg (the composer's + `completionCurrentBg` model), not just a glyph. Number + background + accent = three signals. +- **Inline custom answer (F5):** render the `` **inside the same screen, always + present** as the last "row" (an `Other:` labeled input), instead of an item that toggles. + Selecting/focusing it lets the user type; Enter in it submits the free text. Keep the + existing `clarify.respond {answer}` wiring. Arrow-down past the last choice lands on the + input; arrow-up from the input returns to the list (focus handoff like the composer↔tray). +- Keep Esc/Ctrl+C → cancel (clarifyPrompt.tsx:31-33). + +**Reference:** opencode's selection/list components in `~/github/opencode/packages/tui` for +the wrap + highlight + hotkey idiom; the composer dropdown (composer.tsx:441-458) for the +in-repo highlight/selectable pattern. + +**Tests:** `test/render.test.tsx`-style headless frame — long option wraps (frame contains the +tail of a long choice on a 2nd line), selected row shows numbered + highlighted, custom input +present in the same frame, arrow keys don't change scrollTop (assert transcript scroll +unchanged), Enter on a choice → onAnswer(choice), Enter in input → onAnswer(typed). + +--- + +## F9 — `!cmd` runs bash (`entry/main.tsx` + a system render) + +**Backend exists:** `shell.exec` (server.py:10301) runs the command (30s timeout, dangerous/ +hardline-command guards, returns `{stdout, stderr, code}`). +**Ink parity reference:** `ui-tui/src/app/useSubmission.ts:291` — `full.startsWith('!')` → +`shellExec(full.slice(1).trim())` → appends a user line `!cmd` + a system line with output; +the prompt glyph flips while the buffer starts with `!` (appLayout.tsx:178). + +**Plan (ui-opentui):** +- In the entry `submit` (main.tsx:517-520), add a branch BEFORE the slash check: + `if (text.startsWith('!')) { runShell(text.slice(1).trim()); return }`. +- `runShell(cmd)`: `store.pushUser('!' + cmd)` (echo the invocation in the transcript), then + `gateway.request('shell.exec', { command: cmd })`; on resolve, `store.pushSystem` the + combined `stdout`/`stderr` (or the error message / non-zero `code`); on reject, + pushSystem the error. Detached `runFork` like `submitPrompt`. No session turn, no model call. +- Empty `!` (just the bang) → no-op (or a hint), matching Ink. +- **Optional polish (parity, not required):** flip the composer prompt glyph (or tint) while + the buffer starts with `!`, like Ink's appLayout. Low-risk; do only if cheap. + +**Tests:** entry-level/logic test that a `!`-prefixed submit routes to `shell.exec` (not +`prompt.submit`), and the system line renders stdout. Mirror the slashMenu.test harness +(fake gateway capturing the method). + +--- + +## Sequencing & fences (subagent-driven; disjoint files) + +Parallel-safe groups (disjoint file fences): +1. **slash trigger** — `logic/slash.ts` (+ `logic/skillMatch.ts` reuse) + `test/slash.test.ts`. (F1/F2/F7/F8/F8b) +2. **clarify** — `view/prompts/clarifyPrompt.tsx` + a clarify test. (F5/F6) +3. **shell-exec** — `entry/main.tsx` (edit DIRECTLY — load-bearing) + system render + test. (F9) +4. **paste focus** — `view/composer.tsx` (edit directly; verify opentui paste API first). (F4) +5. **cost** — `tui_gateway/server.py` + `agent/usage_pricing.py` + gateway test. (F3) — Python, isolated. + +`entry/main.tsx` and `store.ts` are edited directly, never via subagent (handoff rule). +Each renderable change: `skill_view(opentui, references/docs/...)` FIRST. Verify every +subagent self-report (re-run `npm run check` exit code, read the diff). + +## Open decisions (need glitch) +- **D1 (F7/F8):** thread cursor offset into `onType` (correct) vs. last-line slice (cheap)? + Recommend cursor offset. +- **D2 (F3):** drop OpenRouter cost source entirely, or gate it to the Nous route? Recommend + Nous-route gate via a status-bar-only helper, leaving `/usage` accounting intact. + +## Invariants to preserve +- Per-conversation prompt caching untouched (all client-render or post-hoc gateway usage). +- No new `HERMES_*` env var (these are behaviour, not secrets). +- Strict no change-detector tests — assert behaviour/invariants. +- Don't regress the `/usage` accounting page when narrowing the chrome cost source. diff --git a/ui-opentui/src/entry/main.tsx b/ui-opentui/src/entry/main.tsx index 000feb9fdbd..e2d18c746e9 100644 --- a/ui-opentui/src/entry/main.tsx +++ b/ui-opentui/src/entry/main.tsx @@ -39,6 +39,7 @@ import { createPromptHistory, dirHistoryPersister, loadDirHistory } from '../log import { createPasteStore } from '../logic/pastes.ts' import { mapResumeHistory } from '../logic/resume.ts' import { + classifySubmit, dispatchSlash, mapCompletions, mapModelOptions, @@ -431,6 +432,33 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { ) } + // `!cmd` — run a shell command directly (Ink/free-code parity: F9). The + // gateway's `shell.exec` runs it (30s timeout, dangerous/hardline guards) + // and returns {stdout, stderr, code}; we echo the invocation as a user line + // and the combined output (or the error / non-zero exit) as a system line. + // No model turn — this never hits prompt.submit. Detached like submitPrompt. + const runShell = (cmd: string) => { + if (!cmd) return + store.pushUser(`!${cmd}`) + Effect.runFork( + gateway.request<{ stdout?: string; stderr?: string; code?: number }>('shell.exec', { command: cmd }).pipe( + Effect.tap(r => + Effect.sync(() => { + const out = [r.stdout, r.stderr].filter(Boolean).join('\n').trimEnd() + if (out) store.pushSystem(out) + if ((r.code ?? 0) !== 0 || !out) store.pushSystem(`exit ${r.code ?? 0}`) + }) + ), + Effect.catchCause(cause => + Effect.sync(() => { + getLog().warn('shell', 'failed', { cause: String(cause) }) + store.pushSystem(`error: ${String(cause)}`) + }) + ) + ) + ) + } + // Resume a chosen session (resume picker pick or `/resume ` direct // path) — the same hydrate path as launch. When the picker was the BOOT // surface (bare `--resume`), no create ever ran, so the post-session @@ -513,10 +541,13 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { submit: submitPrompt } - // The composer's submit: route `/command` through the slash ladder, else a prompt. + // The composer's submit: `!cmd` runs a shell command (F9), `/command` + // routes through the slash ladder, else a prompt turn. const submit = (text: string) => { - if (text.startsWith('/')) void dispatchSlash(text, slashCtx) - else submitPrompt(text) + const route = classifySubmit(text) + if (route.kind === 'shell') runShell(route.payload) + else if (route.kind === 'slash') void dispatchSlash(route.payload, slashCtx) + else submitPrompt(route.payload) } // Live completions (items 5 + 13): a `/command [args]` line queries @@ -525,8 +556,8 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) { // accepted item replaces from the gateway's `replace_from` (or the token // start), so only the relevant token is spliced — not the whole line. // Fired per keystroke (a debounce is a polish item). - const onType = (text: string) => { - const plan = planCompletion(text) + const onType = (text: string, cursor: number = text.length) => { + const plan = planCompletion(text, cursor) if (!plan) { store.clearCompletions() return diff --git a/ui-opentui/src/logic/slash.ts b/ui-opentui/src/logic/slash.ts index f31b6f7a1f6..e82ad9fdc05 100644 --- a/ui-opentui/src/logic/slash.ts +++ b/ui-opentui/src/logic/slash.ts @@ -32,6 +32,21 @@ export function parseSlash(input: string): ParsedSlash | null { return sp === -1 ? { arg: '', name: body } : { arg: body.slice(sp + 1).trim(), name: body.slice(0, sp) } } +/** How a submitted composer line is routed (F9 + slash ladder): a `!cmd` runs a + * shell command, a `/command` goes through the slash dispatcher, everything else + * is a prompt turn. `payload` is the command (shell) with the lead `!` stripped + * and trimmed, or the original text (slash/prompt). */ +export type SubmitRoute = + | { kind: 'shell'; payload: string } + | { kind: 'slash'; payload: string } + | { kind: 'prompt'; payload: string } + +export function classifySubmit(text: string): SubmitRoute { + if (text.startsWith('!')) return { kind: 'shell', payload: text.slice(1).trim() } + if (text.startsWith('/')) return { kind: 'slash', payload: text } + return { kind: 'prompt', payload: text } +} + /** The host capabilities the dispatcher needs (wired by the entry boundary). */ export interface SlashContext { /** Server RPC (resolves with the result, rejects on GatewayError). */ @@ -89,33 +104,55 @@ export interface CompletionPlan { from: number } -/** A path-like last token worth file/@-mention completion (mirrors Ink's TAB_PATH_RE intent). */ +/** The command-name grammar for the lead `/token` (mirrors skillMatch NAME_RE): + * starts alphanumeric, then word chars / `.` / `-`. Notably EXCLUDES `/`, so a + * path like `/usr/bin` is NEVER a slash command (F2). */ +const SLASH_NAME_RE = /^[A-Za-z0-9][\w.-]*$/ + +/** `@`-mention is the ONLY file/dir completion trigger now (F8b — glitch + * 2026-06-13: drop `~`/`./`/`/`/bare-path as triggers; the gateway's + * complete.path still understands `@file:`/`@folder:`/fuzzy basename). */ function isPathLike(word: string): boolean { - return ( - word.startsWith('@') || - word.startsWith('~') || - word.startsWith('./') || - word.startsWith('../') || - word.startsWith('/') || - word.includes('/') - ) + return word.startsWith('@') } /** - * Decide what to complete for the current composer text (cursor assumed at end): - * - `/command [args]` → `complete.slash {text}` (the gateway completes names AND - * args, e.g. /details section names), - * - a trailing path-like word (`@…`, `~/…`, `./…`, `/…`, or anything with `/`) → - * `complete.path {word}` for file/dir tagging, + * Decide what to complete for the composer text + cursor offset: + * - the text is a slash command — `/` at the very start followed by at least + * one command-name char (`/m`, `/model foo`) → `complete.slash {text}`. A + * bare `/` (F1) or a `/abs/path` whose first token isn't a valid name (F2) → + * no slash menu. + * - the WORD under the cursor is an `@`-mention → `complete.path {word}` for + * file/dir tagging (F8b). * - otherwise nothing. + * + * Cursor-aware (F7/F8): completion is computed from the line/token at the cursor, + * so it keeps working on later lines after Shift+Enter (the old whole-buffer + * `includes('\n')` bail killed it on every multi-line buffer). `cursor` defaults + * to the end of `text`. Slash commands stay first-line-only (a `/` mid-buffer is + * prose, never a command). * Returns null when there's no completion to run (so the dropdown clears). */ -export function planCompletion(text: string): CompletionPlan | null { - if (text.includes('\n')) return null - if (text.startsWith('/')) return { from: 0, method: 'complete.slash', params: { text } } - const word = /(\S+)$/.exec(text)?.[1] - if (word && isPathLike(word)) { - return { from: text.length - word.length, method: 'complete.path', params: { word } } +export function planCompletion(text: string, cursor: number = text.length): CompletionPlan | null { + // Slash command: only when the WHOLE buffer's lead token is a command. A `/` + // after a newline is prose, so a slash command never spans lines. + if (text.startsWith('/') && !text.includes('\n')) { + const body = text.slice(1) + const space = body.search(/\s/) + const name = space === -1 ? body : body.slice(0, space) + if (SLASH_NAME_RE.test(name)) { + return { from: 0, method: 'complete.slash', params: { text } } + } + return null + } + // @-mention: the whitespace-delimited token the cursor sits in/just after. + const pos = Math.max(0, Math.min(cursor, text.length)) + const head = text.slice(0, pos) + const tokenStart = head.search(/\S+$/) + if (tokenStart === -1) return null + const word = head.slice(tokenStart) + if (isPathLike(word)) { + return { from: tokenStart, method: 'complete.path', params: { word } } } return null } diff --git a/ui-opentui/src/test/agentsTray.test.tsx b/ui-opentui/src/test/agentsTray.test.tsx index 01117ee09e4..eb007a2b772 100644 --- a/ui-opentui/src/test/agentsTray.test.tsx +++ b/ui-opentui/src/test/agentsTray.test.tsx @@ -175,7 +175,7 @@ describe('agents tray — Down-arrow focus routing', () => { try { spawn(h.store, 'a1', 'research X') await h.probe.waitForFrame(f => f.includes(INDICATOR)) - await h.probe.keys.typeText('/') + await h.probe.keys.typeText('/c') await h.probe.settle() await h.probe.waitForFrame(f => f.includes('/copy')) h.probe.keys.pressArrow('down') // menu: /clear → /copy (NOT the tray) diff --git a/ui-opentui/src/test/promptHistoryView.test.tsx b/ui-opentui/src/test/promptHistoryView.test.tsx index 34151ab3f74..cb0b50d48ae 100644 --- a/ui-opentui/src/test/promptHistoryView.test.tsx +++ b/ui-opentui/src/test/promptHistoryView.test.tsx @@ -140,7 +140,7 @@ describe('Esc+Esc — opening the viewer', () => { test('an Esc that dismissed the completion dropdown does NOT arm the double-press', async () => { const h = await mount(['a prompt']) try { - await h.probe.keys.typeText('/') + await h.probe.keys.typeText('/c') await h.probe.settle() await h.probe.waitForFrame(f => f.includes('/clear')) // dropdown open h.probe.keys.pressEscape() // consumed: dismisses the dropdown diff --git a/ui-opentui/src/test/skillSuggest.test.tsx b/ui-opentui/src/test/skillSuggest.test.tsx index 3f0fcfaff31..ea9611f1332 100644 --- a/ui-opentui/src/test/skillSuggest.test.tsx +++ b/ui-opentui/src/test/skillSuggest.test.tsx @@ -152,9 +152,9 @@ describe('anti-jank: mid-prose never completes, never autocorrects', () => { const h = await mountComposer() try { // learn the catalog first (a prior slash interaction), then go mid-prose - await h.probe.keys.typeText('/') + await h.probe.keys.typeText('/c') await h.probe.settle() - for (let i = 0; i < 1; i++) h.probe.keys.pressBackspace() + for (let i = 0; i < 2; i++) h.probe.keys.pressBackspace() await h.probe.settle() await h.probe.keys.typeText('use /comit here') await h.probe.settle() @@ -218,9 +218,9 @@ describe('exact-match token highlight (native editBuffer ranges)', () => { const h = await mountComposer() try { // learn the catalog, then clear back to empty - await h.probe.keys.typeText('/') + await h.probe.keys.typeText('/h') await h.probe.settle() - h.probe.keys.pressBackspace() + for (let i = 0; i < 2; i++) h.probe.keys.pressBackspace() await h.probe.settle() await h.probe.keys.typeText('use /help here') await h.probe.settle() diff --git a/ui-opentui/src/test/slash.test.ts b/ui-opentui/src/test/slash.test.ts index 8c99e649ca4..b97fc4d27c8 100644 --- a/ui-opentui/src/test/slash.test.ts +++ b/ui-opentui/src/test/slash.test.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, test } from 'vitest' import type { DetailsMode } from '../logic/details.ts' import { buildModelTabs, + classifySubmit, clientCommandNames, dispatchSlash, mapCompletions, @@ -77,7 +78,7 @@ describe('mapCompletions', () => { }) describe('planCompletion (items 5 + 13)', () => { - test('a slash line → complete.slash with the full text (name AND args)', () => { + test('a slash command line → complete.slash with the full text (name AND args)', () => { expect(planCompletion('/mod')).toEqual({ from: 0, method: 'complete.slash', params: { text: '/mod' } }) // args too — the gateway completes e.g. /details section names expect(planCompletion('/details thi')).toEqual({ @@ -87,28 +88,67 @@ describe('planCompletion (items 5 + 13)', () => { }) }) - test('a trailing path-like word → complete.path with that word + token start offset', () => { + test('a bare `/` does NOT open the slash menu (F1)', () => { + expect(planCompletion('/')).toBeNull() + expect(planCompletion('/ ')).toBeNull() + }) + + test('a `/abs/path` is NOT a slash command — the lead token has a `/` (F2)', () => { + expect(planCompletion('/usr/bin')).toBeNull() + expect(planCompletion('/etc/hosts and notes')).toBeNull() + expect(planCompletion('/./x')).toBeNull() + }) + + test('@-mention is the only path trigger (F8b) — `~`/`./`/bare paths no longer fire', () => { expect(planCompletion('explain @src/fo')).toEqual({ from: 'explain '.length, method: 'complete.path', params: { word: '@src/fo' } }) - expect(planCompletion('cat ./rea')).toEqual({ - from: 'cat '.length, - method: 'complete.path', - params: { word: './rea' } - }) - expect(planCompletion('open ~/proj')).toEqual({ - from: 'open '.length, - method: 'complete.path', - params: { word: '~/proj' } - }) + expect(planCompletion('@foo')).toEqual({ from: 0, method: 'complete.path', params: { word: '@foo' } }) + // dropped triggers: + expect(planCompletion('cat ./rea')).toBeNull() + expect(planCompletion('open ~/proj')).toBeNull() + expect(planCompletion('see path/to/x')).toBeNull() }) - test('plain prose / multiline → no completion', () => { + test('completion survives newlines, computed at the cursor (F7/F8)', () => { + // a `@`-mention on a later line (after Shift+Enter) still completes + const text = 'first line\nexplain @src/fo' + expect(planCompletion(text, text.length)).toEqual({ + from: 'first line\nexplain '.length, + method: 'complete.path', + params: { word: '@src/fo' } + }) + // mid-buffer: cursor inside the @token on line 2 + const t2 = 'see @foo\nmore' + expect(planCompletion(t2, 8)).toEqual({ from: 4, method: 'complete.path', params: { word: '@foo' } }) + }) + + test('a `/` after a newline is prose, never a slash command', () => { + expect(planCompletion('/cmd with\nnewline')).toBeNull() + }) + + test('plain prose → no completion', () => { expect(planCompletion('just some words')).toBeNull() expect(planCompletion('hello')).toBeNull() - expect(planCompletion('/cmd with\nnewline')).toBeNull() + }) +}) + +describe('classifySubmit (F9 routing)', () => { + test('a `!cmd` line routes to shell with the bang stripped + trimmed', () => { + expect(classifySubmit('!ls -la')).toEqual({ kind: 'shell', payload: 'ls -la' }) + expect(classifySubmit('! git status ')).toEqual({ kind: 'shell', payload: 'git status' }) + expect(classifySubmit('!')).toEqual({ kind: 'shell', payload: '' }) + }) + + test('a `/command` line routes to slash with the full text', () => { + expect(classifySubmit('/model opus')).toEqual({ kind: 'slash', payload: '/model opus' }) + }) + + test('everything else is a prompt turn', () => { + expect(classifySubmit('hello world')).toEqual({ kind: 'prompt', payload: 'hello world' }) + expect(classifySubmit('explain @src/x')).toEqual({ kind: 'prompt', payload: 'explain @src/x' }) }) }) diff --git a/ui-opentui/src/test/slashMenu.test.tsx b/ui-opentui/src/test/slashMenu.test.tsx index 32d6ba966b3..f33ff6eca67 100644 --- a/ui-opentui/src/test/slashMenu.test.tsx +++ b/ui-opentui/src/test/slashMenu.test.tsx @@ -129,16 +129,29 @@ async function mountComposer(historyEntries: string[] = []): Promise { return { probe, submitted, typed } } -describe('slash menu — typing `/` opens the catalog dropdown', () => { - test('`/` as the first char shows the candidates + the nav hint', async () => { +describe('slash menu — opens only after a name char (F1)', () => { + test('a bare `/` does NOT open the menu (F1 — type a char first)', async () => { const h = await mountComposer() try { await h.probe.keys.typeText('/') await h.probe.settle() + const frame = h.probe.frame() + expect(frame).not.toContain('/clear') + expect(frame).not.toContain('Esc dismiss') + expect(frame).toContain('/') // the slash stays in the composer + } finally { + h.probe.destroy() + } + }) + + test('`/c` shows the matching candidates + the nav hint', async () => { + const h = await mountComposer() + try { + await h.probe.keys.typeText('/c') + await h.probe.settle() const frame = await h.probe.waitForFrame(f => f.includes('/clear')) expect(frame).toContain('/copy') - expect(frame).toContain('/help') - expect(frame).toContain('/model') + expect(frame).not.toContain('/help') // filtered out by the `/c` prefix expect(frame).toContain('↑/↓ select') } finally { h.probe.destroy() @@ -164,16 +177,16 @@ describe('slash menu — arrow navigation + Enter accept', () => { test('ArrowDown moves the selection; Enter accepts the highlighted command (no submit)', async () => { const h = await mountComposer() try { - await h.probe.keys.typeText('/') + await h.probe.keys.typeText('/c') // → /clear, /copy await h.probe.settle() - await h.probe.waitForFrame(f => f.includes('/model')) + await h.probe.waitForFrame(f => f.includes('/copy')) h.probe.keys.pressArrow('down') // /clear → /copy await h.probe.settle() h.probe.keys.pressEnter() await h.probe.settle() const frame = h.probe.frame() expect(frame).toContain('/copy') // spliced into the composer … - expect(frame).not.toContain('/clear') // … and the menu is gone + expect(frame).not.toContain('Esc dismiss') // … and the menu is gone expect(h.submitted).toEqual([]) // Enter ACCEPTED, did not submit expect(h.typed.at(-1)).toBe('/copy ') // trailing space → arg-completion re-query ran } finally { @@ -184,16 +197,15 @@ describe('slash menu — arrow navigation + Enter accept', () => { test('ArrowUp from the top wraps to the LAST candidate', async () => { const h = await mountComposer() try { - await h.probe.keys.typeText('/') + await h.probe.keys.typeText('/c') // → /clear, /copy await h.probe.settle() - await h.probe.waitForFrame(f => f.includes('/model')) - h.probe.keys.pressArrow('up') // wraps 0 → 3 (/model) + await h.probe.waitForFrame(f => f.includes('/copy')) + h.probe.keys.pressArrow('up') // wraps 0 → 1 (/copy) await h.probe.settle() h.probe.keys.pressEnter() await h.probe.settle() - expect(h.probe.frame()).toContain('/model') + expect(h.typed.at(-1)).toBe('/copy ') expect(h.submitted).toEqual([]) - expect(h.typed.at(-1)).toBe('/model ') } finally { h.probe.destroy() } @@ -202,10 +214,10 @@ describe('slash menu — arrow navigation + Enter accept', () => { test('ArrowDown past the bottom wraps to the FIRST candidate', async () => { const h = await mountComposer() try { - await h.probe.keys.typeText('/') + await h.probe.keys.typeText('/c') // → /clear, /copy await h.probe.settle() - await h.probe.waitForFrame(f => f.includes('/model')) - for (let i = 0; i < 4; i++) h.probe.keys.pressArrow('down') // 0→1→2→3→0 + await h.probe.waitForFrame(f => f.includes('/copy')) + for (let i = 0; i < 2; i++) h.probe.keys.pressArrow('down') // 0→1→0 await h.probe.settle() h.probe.keys.pressEnter() await h.probe.settle() @@ -277,10 +289,10 @@ describe('slash menu — Esc / Tab / no-dropdown routing', () => { test('arrows while the slash menu is open do NOT touch prompt history', async () => { const h = await mountComposer(['older prompt']) try { - await h.probe.keys.typeText('/') + await h.probe.keys.typeText('/c') await h.probe.settle() - await h.probe.waitForFrame(f => f.includes('/model')) - h.probe.keys.pressArrow('up') // menu nav (wraps to /model), NOT history + await h.probe.waitForFrame(f => f.includes('/copy')) + h.probe.keys.pressArrow('up') // menu nav (wraps), NOT history await h.probe.settle() expect(h.probe.frame()).not.toContain('older prompt') } finally { diff --git a/ui-opentui/src/test/statusChrome.test.tsx b/ui-opentui/src/test/statusChrome.test.tsx index ada78436e85..09a0fb1ed8e 100644 --- a/ui-opentui/src/test/statusChrome.test.tsx +++ b/ui-opentui/src/test/statusChrome.test.tsx @@ -244,12 +244,16 @@ describe('StatusBar frames (one left-aligned labeled line)', () => { expect(rows.filter(r => r.includes('│')).length).toBe(1) }) - test('left-aligned: no right-pinned cwd — the row ends where the segments end', async () => { - const frame = await captureFrame(bar(seededStore()), { width: 220, height: 4 }) + test('right-pinned cwd (F10) — the path hugs the right edge of the wide row', async () => { + const width = 220 + const frame = await captureFrame(bar(seededStore()), { width, height: 4 }) const row = frame.split('\n').find(r => r.includes('claude-opus-4-8')) ?? '' - // the cwd flows immediately after the mcp segment, not at the right edge: - // a 220-col row with a left-flowing tail has a long blank run after it. - expect(row.trimEnd().length).toBeLessThan(160) + // the cwd is pinned right: the row's content reaches near the right edge + // (a flex spacer eats the slack), not stopping ~mid-bar as the old + // left-flowing layout did. Allow a couple cells of padding/rounding. + expect(row.trimEnd().length).toBeGreaterThan(width - 6) + // and the meaningful tail (dirname + branch) sits at the very end. + expect(row.trimEnd().endsWith('(main)')).toBe(true) }) test('MEDIUM (120) keeps one labeled line; the cwd tail-truncates into the leftover budget', async () => { diff --git a/ui-opentui/src/view/App.tsx b/ui-opentui/src/view/App.tsx index 53a52afd108..7c4053095e8 100644 --- a/ui-opentui/src/view/App.tsx +++ b/ui-opentui/src/view/App.tsx @@ -40,7 +40,7 @@ import { Transcript } from './transcript.tsx' export interface AppProps { readonly store: SessionStore readonly onSubmit?: (text: string) => void - readonly onType?: (text: string) => void + readonly onType?: (text: string, cursor: number) => void readonly onRespond?: (method: string, params: Record) => void readonly onResume?: (sessionId: string) => void /** Gateway calls for the resume picker (session.list/peek/title). */ diff --git a/ui-opentui/src/view/composer.tsx b/ui-opentui/src/view/composer.tsx index 746fc515f99..6ad0e10b9f2 100644 --- a/ui-opentui/src/view/composer.tsx +++ b/ui-opentui/src/view/composer.tsx @@ -121,7 +121,7 @@ function isPrintableKey(k: { export function Composer(props: { onSubmit: (text: string) => void - onType?: ((text: string) => void) | undefined + onType?: ((text: string, cursor: number) => void) | undefined completions?: (() => CompletionItem[]) | undefined completionFrom?: (() => number) | undefined onDismiss?: (() => void) | undefined @@ -527,7 +527,7 @@ export function Composer(props: { const text = ta?.plainText ?? '' setBufText(text) // drives the token analysis (highlight + suggestion) syncCursorLine() - props.onType?.(text) + props.onType?.(text, ta?.cursorOffset ?? text.length) }} /> diff --git a/ui-opentui/src/view/statusBar.tsx b/ui-opentui/src/view/statusBar.tsx index 953f2adcec6..74bc9ce4544 100644 --- a/ui-opentui/src/view/statusBar.tsx +++ b/ui-opentui/src/view/statusBar.tsx @@ -270,6 +270,11 @@ export function StatusBar(props: { store: SessionStore }) { } return len }) + // The cwd is RIGHT-PINNED on its own (F10 — glitch 2026-06-13): left-aligning + // it with everything else forced its head to truncate (`…/hermes-agent/ui-…`) + // and stranded empty space at the right edge. Pinned right with a flex spacer, + // the dirname + branch hug the edge and only the head clips (truncLeft). Its + // budget is the row width minus the left run; it drops whole below CWD_MIN. const cwdText = createMemo(() => { const cwd = info().cwd const c = cwd ? shortCwd(cwd) : '' @@ -337,8 +342,15 @@ export function StatusBar(props: { store: SessionStore }) { {/* `bg: N` would slot here (segs().bg) — no store data feeds it yet (see header). */} - + {/* the cwd is RIGHT-PINNED (F10): a flex spacer eats the slack so the + dirname + branch hug the right edge instead of stranding empty navy. */} + + + + {cwdText()} + + )