From 6fdc5b62d86e3feff2bcc7c21463ea4f11ecc30b Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Thu, 9 Jul 2026 23:24:59 -0700 Subject: [PATCH] feat(tui): Tab accepts the composer's suggested query placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain Tab on a fresh, idle, empty composer inserts the query suggested by the dim `Try "…"` placeholder (cursor at end); an open-ended stub like `write a test for…` drops the ellipsis and keeps a trailing space, and a suggested /command pops its completion menu exactly as if typed. Mirrors original CC's Tab fallback (useTypeahead.tsx: tab / no shift / no suggestions / empty input → accept the prompt suggestion). Gated on the exact placeholder-visibility condition (conversation empty, not busy, empty input, no completions) so Tab can never insert what isn't displayed, and never shadows completion-accept or Shift+Tab mode cycle. Co-Authored-By: Claude Fable 5 --- .../src/__tests__/tabAcceptSuggestion.test.ts | 97 +++++++++++++++++++ ui-tui/src/app/interfaces.ts | 4 + ui-tui/src/app/useInputHandlers.ts | 39 +++++++- ui-tui/src/app/useMainApp.ts | 1 + ui-tui/src/content/placeholders.ts | 29 ++++++ 5 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 ui-tui/src/__tests__/tabAcceptSuggestion.test.ts diff --git a/ui-tui/src/__tests__/tabAcceptSuggestion.test.ts b/ui-tui/src/__tests__/tabAcceptSuggestion.test.ts new file mode 100644 index 000000000..443ea610c --- /dev/null +++ b/ui-tui/src/__tests__/tabAcceptSuggestion.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' + +import { shouldAcceptPlaceholderSuggestion } from '../app/useInputHandlers.js' +import { PLACEHOLDERS, suggestedQuery } from '../content/placeholders.js' + +describe('suggestedQuery — extract the tab-acceptable query from a placeholder', () => { + it('returns the quoted query verbatim', () => { + expect(suggestedQuery('Try "explain this codebase"')).toBe('explain this codebase') + expect(suggestedQuery('Try "fix the lint errors"')).toBe('fix the lint errors') + }) + + it('keeps trailing punctuation that is part of the query', () => { + expect(suggestedQuery('Try "how does the config loader work?"')).toBe('how does the config loader work?') + }) + + it('extracts a suggested slash command, ignoring prose after the quotes', () => { + expect(suggestedQuery('Try "/help" for commands')).toBe('/help') + }) + + it('takes the first quoted span when several appear', () => { + expect(suggestedQuery('Try "first" or "second"')).toBe('first') + }) + + it('turns an open-ended stub into a continuable sentence (ellipsis → trailing space)', () => { + expect(suggestedQuery('Try "write a test for…"')).toBe('write a test for ') + expect(suggestedQuery('Try "write a test for..."')).toBe('write a test for ') + }) + + it('returns null when the placeholder carries no quoted query', () => { + expect(suggestedQuery('Ask me anything…')).toBeNull() + expect(suggestedQuery('')).toBeNull() + }) + + it('returns null when the quotes hold nothing but an ellipsis', () => { + expect(suggestedQuery('Try "…"')).toBeNull() + expect(suggestedQuery('Try "..."')).toBeNull() + }) + + it('every shipped placeholder yields null or a clean non-empty query', () => { + const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + + for (const placeholder of PLACEHOLDERS) { + const query = suggestedQuery(placeholder) + + if (query === null) { + continue + } + + expect(query.length).toBeGreaterThan(0) + expect(query).not.toMatch(/(?:…|\.{3})$/) + // Round-trip: the extracted query must sit in the placeholder as a + // whole quoted span (optionally ellipsis-terminated). Bites when a + // nested quote silently truncates the extraction. + expect(placeholder).toMatch(new RegExp(`"${escape(query.trimEnd())}(?:…|\\.{3})?"`)) + } + }) + + it('the shipped list still contains tab-acceptable suggestions', () => { + expect(PLACEHOLDERS.some(p => suggestedQuery(p) !== null)).toBe(true) + }) +}) + +describe('shouldAcceptPlaceholderSuggestion — Tab accepts only what is visibly suggested', () => { + const visible = { busy: false, completionsLen: 0, conversationEmpty: true, input: '' } + + it('accepts plain Tab while the placeholder suggestion is showing', () => { + expect(shouldAcceptPlaceholderSuggestion({ shift: false, tab: true }, visible)).toBe(true) + }) + + it('never fires for Shift+Tab — that cycles the permission mode', () => { + expect(shouldAcceptPlaceholderSuggestion({ shift: true, tab: true }, visible)).toBe(false) + }) + + it('never fires for non-Tab keys', () => { + expect(shouldAcceptPlaceholderSuggestion({ shift: false, tab: false }, visible)).toBe(false) + }) + + it('defers to an open completion menu', () => { + expect(shouldAcceptPlaceholderSuggestion({ shift: false, tab: true }, { ...visible, completionsLen: 2 })).toBe( + false + ) + }) + + it('stays inert once the user typed something (placeholder is hidden)', () => { + expect(shouldAcceptPlaceholderSuggestion({ shift: false, tab: true }, { ...visible, input: 'h' })).toBe(false) + }) + + it('stays inert mid-conversation (placeholder is only shown on a fresh transcript)', () => { + expect( + shouldAcceptPlaceholderSuggestion({ shift: false, tab: true }, { ...visible, conversationEmpty: false }) + ).toBe(false) + }) + + it('stays inert while a turn is running (placeholder is hidden when busy)', () => { + expect(shouldAcceptPlaceholderSuggestion({ shift: false, tab: true }, { ...visible, busy: true })).toBe(false) + }) +}) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index e0c07d711..8edc1ddb4 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -287,6 +287,10 @@ export interface InputHandlerContext { refs: ComposerRefs state: ComposerState } + /** True while the transcript holds nothing but the intro — the state in + * which appLayout shows the `Try "…"` placeholder, and the only state in + * which Tab may accept its suggested query. */ + conversationEmpty: boolean gateway: GatewayServices terminal: { hasSelection: boolean diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index c7b4a7a1f..3718f63f7 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react' import { DASHBOARD_TUI_MODE } from '../config/env.js' import { TYPING_IDLE_MS } from '../config/timing.js' +import { PLACEHOLDER, suggestedQuery } from '../content/placeholders.js' import type { ApprovalRespondResponse, SecretRespondResponse, @@ -79,6 +80,22 @@ export function shouldFallThroughForScroll(key: { return false } +/** + * Plain Tab accepts the composer placeholder's suggested query (`Try "…"`). + * True only while that suggestion is actually visible — fresh conversation, + * idle, empty input (appLayout gates the placeholder on the first two; + * TextInput hides it once the input has text) — and only for unmodified Tab + * with no completion menu open, so it can never shadow completion-accept or + * the Shift+Tab permission-mode cycle. Mirrors original CC's Tab fallback + * (useTypeahead.tsx handleKeyDown: tab / no shift / no suggestions / empty + * input → insert the prompt suggestion). + */ +export const shouldAcceptPlaceholderSuggestion = ( + key: { shift: boolean; tab: boolean }, + state: { busy: boolean; completionsLen: number; conversationEmpty: boolean; input: string } +): boolean => + key.tab && !key.shift && !state.completionsLen && !state.input && state.conversationEmpty && !state.busy + export function applyVoiceRecordResponse( response: null | VoiceRecordResponse, starting: boolean, @@ -100,7 +117,7 @@ export function applyVoiceRecordResponse( } export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { - const { actions, composer, gateway, terminal, voice, wheelStep } = ctx + const { actions, composer, conversationEmpty, gateway, terminal, voice, wheelStep } = ctx const { actions: cActions, refs: cRefs, state: cState } = composer const overlay = useStore($overlayState) @@ -662,6 +679,26 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return } + if ( + shouldAcceptPlaceholderSuggestion(key, { + busy: live.busy, + completionsLen: cState.completions.length, + conversationEmpty, + input: cState.input + }) + ) { + const query = suggestedQuery(PLACEHOLDER) + + // 'Ask me anything…' carries no query — Tab stays inert. On accept the + // external value change snaps TextInput's cursor to the end, and a + // suggested `/command` pops its completion menu exactly as if typed. + if (query) { + cActions.setInput(query) + } + + return + } + if (isAction(key, ch, 'k') && cRefs.queueRef.current.length && live.sid) { const next = cActions.dequeue() diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index efbe3dfd2..a3e52a737 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -851,6 +851,7 @@ export function useMainApp(gw: GatewayClient) { sys }, composer: { actions: composerActions, refs: composerRefs, state: composerState }, + conversationEmpty: empty, gateway, terminal: { hasSelection, scrollRef, scrollWithSelection, selection, stdout }, voice: { diff --git a/ui-tui/src/content/placeholders.ts b/ui-tui/src/content/placeholders.ts index 3d97eecac..06b5a4fb3 100644 --- a/ui-tui/src/content/placeholders.ts +++ b/ui-tui/src/content/placeholders.ts @@ -11,3 +11,32 @@ export const PLACEHOLDERS = [ ] export const PLACEHOLDER = pick(PLACEHOLDERS) + +/** + * The tab-acceptable query inside a composer placeholder: `Try "explain this + * codebase"` suggests the query `explain this codebase`; a placeholder with no + * quoted span ('Ask me anything…') suggests nothing. An open-ended stub + * (`Try "write a test for…"`) drops the ellipsis and keeps one trailing space + * so the accepted text reads as a sentence the user finishes typing. + * + * Original CC accepts its prompt suggestion the same way — plain Tab on an + * empty input inserts the suggestion text (useTypeahead.tsx handleKeyDown); + * there the suggestion state already holds the bare query, while ours is + * embedded in the `Try "…"` placeholder string, hence this extraction. + */ +export function suggestedQuery(placeholder: string): null | string { + const quoted = /"([^"]+)"/.exec(placeholder)?.[1] + + if (!quoted) { + return null + } + + const openEnded = /(?:…|\.{3})$/.test(quoted) + const query = quoted.replace(/(?:…|\.{3})$/, '').trimEnd() + + if (!query) { + return null + } + + return openEnded ? `${query} ` : query +}