mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
opentui(v6): composer UX batch — slash trigger, @-mentions, !bash, right-pinned cwd
- F1: slash menu opens only after a name char (bare / no longer fires) - F2: /abs/path is no longer mistaken for a slash command (lead token must match NAME_RE) - F7/F8: completion survives newlines — computed at the cursor token, not whole-buffer bail - F8b: @ is the only file/dir mention trigger (~ / ./ / bare paths dropped) - F9: !cmd runs a shell command via gateway shell.exec (Ink parity), output as a system line - F10: cwd is right-pinned on the chrome bar so dirname+branch hug the right edge planCompletion is now cursor-aware (onType threads ta.cursorOffset). classifySubmit extracted as a pure, tested router. 708 tests green.
This commit is contained in:
parent
b6598017c8
commit
5268027e6b
12 changed files with 448 additions and 72 deletions
240
docs/plans/opentui-composer-ux-9.md
Normal file
240
docs/plans/opentui-composer-ux-9.md
Normal file
|
|
@ -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 `<select>` row that *switches* to an input on Enter rather than being an inline input.
|
||||
|
||||
**Current:** one native `<select>` over `[...choices, {Other}]` (clarifyPrompt.tsx:61-75).
|
||||
Native `<select>` doesn't wrap long rows and (F6) doesn't `preventDefault` arrows, so they
|
||||
leak to the transcript scrollbox.
|
||||
|
||||
**Rewrite plan (verify renderable API in `opentui` skill first):**
|
||||
- Replace native `<select>` 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 `<text>` that wraps to the box width (no fixed
|
||||
single-line). Indent continuation lines under the option label. Confirm `<text>` 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 `<input>` **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.
|
||||
|
|
@ -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 <id>` 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -129,16 +129,29 @@ async function mountComposer(historyEntries: string[] = []): Promise<Harness> {
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>) => void
|
||||
readonly onResume?: (sessionId: string) => void
|
||||
/** Gateway calls for the resume picker (session.list/peek/title). */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
<Seg text={profileText()} fg={theme().color.statusFg} />
|
||||
{/* `bg: N` would slot here (segs().bg) — no store data feeds it yet (see header). */}
|
||||
<Seg text={mcpText()} />
|
||||
<Seg text={cwdText()} />
|
||||
</text>
|
||||
{/* 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. */}
|
||||
<Show when={cwdText()}>
|
||||
<box style={{ flexGrow: 1 }} />
|
||||
<text selectable={false}>
|
||||
<span style={{ fg: theme().color.muted }}>{cwdText()}</span>
|
||||
</text>
|
||||
</Show>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue