Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions ui-tui/src/__tests__/tabAcceptSuggestion.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
4 changes: 4 additions & 0 deletions ui-tui/src/app/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 38 additions & 1 deletion ui-tui/src/app/useInputHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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()

Expand Down
1 change: 1 addition & 0 deletions ui-tui/src/app/useMainApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
29 changes: 29 additions & 0 deletions ui-tui/src/content/placeholders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading