dsh-code 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +5 -5
- package/README.md +5 -5
- package/lib/index.mjs +955 -233
- package/lib/startup.mjs +1 -1
- package/lib/{theme-7u5Qo3dF.mjs → theme-B3orFUYz.mjs} +8 -0
- package/lib/types/app.d.ts +20 -0
- package/lib/types/attachments.d.ts +16 -7
- package/lib/types/history.d.ts +10 -0
- package/lib/types/index.d.ts +1 -1
- package/lib/types/kernel-panels.d.ts +3 -1
- package/lib/types/locales/en.d.ts +49 -1
- package/lib/types/render/status.d.ts +4 -1
- package/lib/types/session-directory.d.ts +25 -1
- package/lib/types/session-switch.d.ts +8 -0
- package/lib/types/update-panel.d.ts +22 -1
- package/lib/types/version.d.ts +2 -0
- package/package.json +7 -5
- package/src/app.ts +288 -166
- package/src/attachments.ts +65 -19
- package/src/authorization-panel.ts +5 -2
- package/src/fork.ts +11 -7
- package/src/history.ts +14 -0
- package/src/index.ts +92 -53
- package/src/input-split.ts +24 -4
- package/src/kernel-panels.ts +60 -27
- package/src/locales/en.ts +50 -1
- package/src/locales/zh.ts +50 -1
- package/src/rainbow.ts +13 -3
- package/src/render/status.ts +80 -29
- package/src/render/text.ts +2 -1
- package/src/session-directory.ts +82 -3
- package/src/session-switch.ts +14 -0
- package/src/store.ts +19 -1
- package/src/update-panel.ts +112 -5
- package/src/version.ts +5 -0
package/src/app.ts
CHANGED
|
@@ -45,10 +45,10 @@ import { parseRainbowArgument, rainbowRoll, rainbowSeedLabel, rerollRainbow } fr
|
|
|
45
45
|
import { ThemePanel } from './theme-panel.ts'
|
|
46
46
|
import { LanguagePanel } from './language-panel.ts'
|
|
47
47
|
import { getLanguage, parseLanguageName, t, type LanguageName, type MessageKey } from './i18n.ts'
|
|
48
|
-
import { UpdatePanel } from './update-panel.ts'
|
|
48
|
+
import { UpdatePanel, subscribeUpdateApplyRunning } from './update-panel.ts'
|
|
49
49
|
import type { LauncherUpdateStatus } from './update.ts'
|
|
50
50
|
import { WHALE_GLYPH, WHALE_GLYPH_COLUMNS } from './whale-glyph.ts'
|
|
51
|
-
import {
|
|
51
|
+
import { dshKernelVersion, headerBrandTitle } from './version.ts'
|
|
52
52
|
import type { TranscriptStore } from './store.ts'
|
|
53
53
|
import { DEFAULT_TERMINAL_TITLE, sanitizeTerminalTitle, terminalTitleSequence, useTerminalTitle } from './terminal-title.ts'
|
|
54
54
|
import { settledEntryCount, type TranscriptEntry } from './render/projection.ts'
|
|
@@ -112,6 +112,7 @@ import {
|
|
|
112
112
|
recallOlder,
|
|
113
113
|
recordLocalEntry,
|
|
114
114
|
type RecallState,
|
|
115
|
+
appendRecall,
|
|
115
116
|
} from './history.ts'
|
|
116
117
|
import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
|
|
117
118
|
import { parseReviewArgument, type GitDiffView, type ReviewBranch, type ReviewCommit, type ReviewSelection } from './git-workflow.ts'
|
|
@@ -125,6 +126,7 @@ import { ProviderAuthorizationLogoutPanel, ProviderAuthorizationPanel } from './
|
|
|
125
126
|
import {
|
|
126
127
|
looksLikeImagePath,
|
|
127
128
|
parsePastedAttachmentPaths,
|
|
129
|
+
looksLikePathDraft,
|
|
128
130
|
type FilePathInspection,
|
|
129
131
|
type ImagePathInspection,
|
|
130
132
|
} from './attachments.ts'
|
|
@@ -158,6 +160,12 @@ function readSettledRowCap(): number {
|
|
|
158
160
|
|
|
159
161
|
/** Reset region/style, clear the visible screen and scrollback, then home. */
|
|
160
162
|
const RESIZE_REFLOW_CLEAR = '\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H'
|
|
163
|
+
/**
|
|
164
|
+
* Same as {@link RESIZE_REFLOW_CLEAR} without wiping native scrollback.
|
|
165
|
+
* History-cap trims remount `<Static>` but must not `\x1b[3J` a user who is
|
|
166
|
+
* reading earlier messages above the fold.
|
|
167
|
+
*/
|
|
168
|
+
const TRIM_REFLOW_CLEAR = '\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[H'
|
|
161
169
|
/** Ask terminals supporting DEC synchronized updates to hold the frame. */
|
|
162
170
|
const SYNCHRONIZED_UPDATE_BEGIN = '\x1b[?2026h'
|
|
163
171
|
/** Release the held frame after Ink has replayed the source-backed Static rows. */
|
|
@@ -285,6 +293,22 @@ const LOCAL_COMMANDS: readonly LocalCommand[] = [
|
|
|
285
293
|
|
|
286
294
|
const LOCAL_COMMAND_NAMES = new Set(LOCAL_COMMANDS.map(command => command.label.slice(1)))
|
|
287
295
|
|
|
296
|
+
/**
|
|
297
|
+
* TUI-local commands that take no input. Extra tokens used to fall through
|
|
298
|
+
* as a prompt (`/queue clear` reached the model); they now surface usage.
|
|
299
|
+
*/
|
|
300
|
+
const BARE_LOCAL_COMMANDS = new Set([
|
|
301
|
+
'quit', 'help', 'clear', 'copy', 'update', 'schedule', 'statusline', 'theme',
|
|
302
|
+
'history', 'queue', 'usage', 'agents', 'todos', 'subagent',
|
|
303
|
+
])
|
|
304
|
+
|
|
305
|
+
/** Split a slash line into the command name and any trailing input. */
|
|
306
|
+
function slashNameAndArgs(text: string): { readonly name: string; readonly args: string } | undefined {
|
|
307
|
+
const match = /^\/([a-z][a-z0-9_-]*)(?:$|[\t ](.*))$/u.exec(text)
|
|
308
|
+
if (match === null || match[1] === undefined) return undefined
|
|
309
|
+
return { name: match[1], args: (match[2] ?? '').trim() }
|
|
310
|
+
}
|
|
311
|
+
|
|
288
312
|
/** One mutation the terminal may request for a pending next-turn inbox item. */
|
|
289
313
|
export type QueueMutation =
|
|
290
314
|
| { readonly kind: 'remove' }
|
|
@@ -621,27 +645,33 @@ function DeepDivingLine({ since, animated = true }: { since: number; animated?:
|
|
|
621
645
|
* cap counts explicit newlines and terminal wrapping, slicing from the END so
|
|
622
646
|
* the freshest tokens stay visible while a long reply streams; the complete
|
|
623
647
|
* text lands in the flushed scrollback once the turn assembles it.
|
|
648
|
+
*
|
|
649
|
+
* Body wrap width for a streaming tail. `rowColumns` is the same width
|
|
650
|
+
* passed to `transcriptEntryLines` (terminal minus the last-column safety);
|
|
651
|
+
* the hanging prefix then shrinks the body so streamed text and settled
|
|
652
|
+
* markdown wrap on the same column.
|
|
624
653
|
*/
|
|
625
|
-
function
|
|
654
|
+
export function streamTailBodyColumns(rowColumns: number, prefix: string, continuationPrefix = prefix): number {
|
|
655
|
+
const width = Math.max(1, Math.floor(rowColumns))
|
|
656
|
+
const prefixColumns = Math.max(visibleColumns(prefix), visibleColumns(continuationPrefix))
|
|
657
|
+
return Math.max(1, width - prefixColumns)
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function StreamTail({ text, dim, maxRows, prefix = '', continuationPrefix = prefix, children, columns }: {
|
|
626
661
|
text: string
|
|
627
662
|
dim: boolean
|
|
628
663
|
maxRows: number
|
|
629
664
|
prefix?: string
|
|
630
665
|
continuationPrefix?: string
|
|
631
666
|
children?: ReactElement
|
|
667
|
+
/** Same physical row width `transcriptEntryLines` uses (terminal minus 2). */
|
|
668
|
+
columns: number
|
|
632
669
|
}): ReactElement {
|
|
633
|
-
const columns = useStdout().stdout?.columns ?? 80
|
|
634
670
|
const safeRows = Math.max(1, maxRows)
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
//
|
|
638
|
-
const
|
|
639
|
-
// Content takes the full physical row minus prefixes and the final wrap
|
|
640
|
-
// column — a forced 10-column FLOOR on a narrower terminal made every row
|
|
641
|
-
// autowrap onto a second, unbudgeted row (the live budget then
|
|
642
|
-
// under-counted and the tree overflowed), so the width now shrinks with
|
|
643
|
-
// the real terminal instead of flooring at 10.
|
|
644
|
-
const contentColumns = Math.max(1, columns - 1 - prefixColumns)
|
|
671
|
+
// Both prefixes participate because every physical row repeats its hanging
|
|
672
|
+
// indent. The wrap matches settled markdown (row width minus prefix), so
|
|
673
|
+
// the flush at turn end does not reflow the last paragraph.
|
|
674
|
+
const contentColumns = streamTailBodyColumns(columns, prefix, continuationPrefix)
|
|
645
675
|
const initial = displayTail(text, contentColumns, safeRows)
|
|
646
676
|
// Reserve one row for the omission marker only when a marker is needed.
|
|
647
677
|
const tail = initial.truncated && safeRows > 1
|
|
@@ -865,9 +895,9 @@ function Header({ resumed }: { resumed: boolean }): ReactElement {
|
|
|
865
895
|
const version = dshKernelVersion()
|
|
866
896
|
return version === undefined ? undefined : `dsh-v${version}`
|
|
867
897
|
})()
|
|
868
|
-
const title =
|
|
898
|
+
const title = headerBrandTitle()
|
|
869
899
|
const slogan = 'Into the Unknown 探索未至之境'
|
|
870
|
-
const hint = resumed ? '
|
|
900
|
+
const hint = resumed ? t('header.hintResumed') : t('header.hint')
|
|
871
901
|
const copyWidths = [visibleColumns(title), visibleColumns(slogan), visibleColumns(hint)]
|
|
872
902
|
if (kernelLine !== undefined) copyWidths.push(visibleColumns(kernelLine))
|
|
873
903
|
const copyColumns = Math.max(...copyWidths)
|
|
@@ -1301,25 +1331,19 @@ function StatusLine({ facts, stats, busy, columns, items, onRows, animated }: {
|
|
|
1301
1331
|
const flowTick = useFrames(BUSY_CHASE_TICK_MS, flowActive)
|
|
1302
1332
|
const flowMs = flowActive ? flowTick * BUSY_CHASE_TICK_MS + (flow?.phaseMs ?? 0) : undefined
|
|
1303
1333
|
const language = getLanguage()
|
|
1304
|
-
const layout = useMemo(() =>
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
facts
|
|
1317
|
-
facts.sandbox,
|
|
1318
|
-
facts.plan,
|
|
1319
|
-
facts.permission,
|
|
1320
|
-
facts.goal?.phase,
|
|
1321
|
-
facts.goal?.rounds,
|
|
1322
|
-
facts.goal?.max,
|
|
1334
|
+
const layout = useMemo(() => {
|
|
1335
|
+
// The layout reads translations through t(); naming the current language
|
|
1336
|
+
// here makes that external store value an explicit cache invalidator.
|
|
1337
|
+
void language
|
|
1338
|
+
return layoutStatusBar(facts, stats, Math.max(8, columns - 2), {
|
|
1339
|
+
busy,
|
|
1340
|
+
items,
|
|
1341
|
+
// Match the composer content budget: border + horizontal padding are
|
|
1342
|
+
// already excluded, and layoutStatusBar shrinks this ceiling as needed.
|
|
1343
|
+
contextWidth: Math.max(5, columns - 6),
|
|
1344
|
+
})
|
|
1345
|
+
}, [
|
|
1346
|
+
facts,
|
|
1323
1347
|
stats,
|
|
1324
1348
|
busy,
|
|
1325
1349
|
columns,
|
|
@@ -1640,7 +1664,7 @@ function QuestionBar({ store, snapshot, locked }: { store: QuestionStore; snapsh
|
|
|
1640
1664
|
}, [request])
|
|
1641
1665
|
|
|
1642
1666
|
const question = pending?.request.questions[index]
|
|
1643
|
-
const options = question?.options ?? []
|
|
1667
|
+
const options = useMemo(() => question?.options ?? [], [question])
|
|
1644
1668
|
const isPlan = question?.intent?.kind === 'plan-review'
|
|
1645
1669
|
const isMulti = question?.multiSelect === true
|
|
1646
1670
|
const currentDraft = drafts[index] ?? initialQuestionDraft(question)
|
|
@@ -1976,7 +2000,7 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
|
|
|
1976
2000
|
const [cursor, setCursor] = useState(0)
|
|
1977
2001
|
const stdout = useStdout().stdout
|
|
1978
2002
|
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
1979
|
-
const rows = directory?.rows ?? []
|
|
2003
|
+
const rows = useMemo(() => directory?.rows ?? [], [directory])
|
|
1980
2004
|
// Direct-typing filter over provider and model names (the /mode contract):
|
|
1981
2005
|
// printable keys edit the query, so a long directory is searchable without
|
|
1982
2006
|
// a separate search mode. With a query active, q/r/g/G stop acting as
|
|
@@ -3327,10 +3351,13 @@ function entryKindLabel(entry: TranscriptEntry | undefined): string {
|
|
|
3327
3351
|
* retained entry is converted to physical rows, but only one viewport slice
|
|
3328
3352
|
* reaches Ink, so even a huge reasoning block cannot grow the dynamic tree.
|
|
3329
3353
|
*/
|
|
3330
|
-
function VerbosePanel({ entries, onClose
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3354
|
+
function VerbosePanel({ entries, onClose, columns, rows }: {
|
|
3355
|
+
entries: readonly TranscriptEntry[]
|
|
3356
|
+
onClose: () => void
|
|
3357
|
+
/** Live terminal columns from App's resize store — not useStdout, so memo cannot skip a reflow. */
|
|
3358
|
+
columns: number
|
|
3359
|
+
rows: number
|
|
3360
|
+
}): ReactElement {
|
|
3334
3361
|
const viewport = inspectorViewport(columns, rows)
|
|
3335
3362
|
const [cursor, setCursor] = useState(() => Math.max(0, entries.length - 1))
|
|
3336
3363
|
const [scroll, setScroll] = useState(0)
|
|
@@ -3343,6 +3370,8 @@ function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[
|
|
|
3343
3370
|
[entry, viewport.contentColumns],
|
|
3344
3371
|
)
|
|
3345
3372
|
const visibleScroll = clampScroll(scroll, allLines.length, viewport.bodyRows)
|
|
3373
|
+
const visibleScrollRef = useRef(visibleScroll)
|
|
3374
|
+
visibleScrollRef.current = visibleScroll
|
|
3346
3375
|
|
|
3347
3376
|
useEffect(() => {
|
|
3348
3377
|
cursorRef.current = cursor
|
|
@@ -3352,7 +3381,7 @@ function VerbosePanel({ entries, onClose }: { entries: readonly TranscriptEntry[
|
|
|
3352
3381
|
const current = cursorRef.current
|
|
3353
3382
|
const next = followInspectorCursor(current, previousLength.current, entries.length)
|
|
3354
3383
|
if (next !== current) {
|
|
3355
|
-
savedScroll.current.set(current,
|
|
3384
|
+
savedScroll.current.set(current, visibleScrollRef.current)
|
|
3356
3385
|
setCursor(next)
|
|
3357
3386
|
setScroll(savedScroll.current.get(next) ?? 0)
|
|
3358
3387
|
}
|
|
@@ -3485,6 +3514,16 @@ interface CompletionCandidate {
|
|
|
3485
3514
|
origin: 'command' | 'skill' | 'mention'
|
|
3486
3515
|
}
|
|
3487
3516
|
|
|
3517
|
+
/**
|
|
3518
|
+
* Wrap one completion-menu cursor step. An empty menu keeps the index at 0:
|
|
3519
|
+
* `% 0` is NaN, and a NaN index silently disables the highlight, the accept
|
|
3520
|
+
* key, and any later Enter that runs through the menu.
|
|
3521
|
+
*/
|
|
3522
|
+
export function stepCompletionIndex(index: number, delta: number, count: number): number {
|
|
3523
|
+
if (count <= 0) return 0
|
|
3524
|
+
return ((index + delta) % count + count) % count
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3488
3527
|
/**
|
|
3489
3528
|
* Resolve completion candidates for the current input: TUI-local commands,
|
|
3490
3529
|
* the live registry descriptors, and user-invocable skills, filtered by the
|
|
@@ -3700,7 +3739,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
|
|
|
3700
3739
|
/** Open the /todos subpage (full todo list in one bounded panel). */
|
|
3701
3740
|
openTodos: () => void
|
|
3702
3741
|
openUsage: () => void
|
|
3703
|
-
/** Open the /
|
|
3742
|
+
/** Open the dedicated /delete picker, optionally pre-armed on one id. */
|
|
3704
3743
|
openDelete: (id?: string) => void
|
|
3705
3744
|
openDiff: (argument: string) => void
|
|
3706
3745
|
reviewChanges: (selection: ReviewSelection) => void
|
|
@@ -3828,27 +3867,28 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
|
|
|
3828
3867
|
preferredColumnRef.current = null
|
|
3829
3868
|
}, [editorColumns])
|
|
3830
3869
|
|
|
3831
|
-
// A /history panel acceptance lands as a fill:
|
|
3832
|
-
// the
|
|
3870
|
+
// A /history panel acceptance lands as a fill: append the sanitized text to
|
|
3871
|
+
// the composer and resume recall from that entry. Appending (never
|
|
3872
|
+
// replacing) is what keeps a half-written draft and its prepared
|
|
3873
|
+
// attachments from being destroyed by picking a history entry.
|
|
3833
3874
|
useEffect(() => {
|
|
3834
3875
|
if (historyFill === undefined) return
|
|
3835
3876
|
const safe = sanitizeDraftText(historyFill.text)
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
setValue(safe)
|
|
3843
|
-
setCursor(safe.length)
|
|
3877
|
+
const current = valueRef.current
|
|
3878
|
+
const joined = appendRecall(current, safe)
|
|
3879
|
+
valueRef.current = joined
|
|
3880
|
+
cursorRef.current = joined.length
|
|
3881
|
+
setValue(joined)
|
|
3882
|
+
setCursor(joined.length)
|
|
3844
3883
|
resetCursorBlink()
|
|
3845
3884
|
preferredColumnRef.current = null
|
|
3846
3885
|
setDismissedMenuValue(undefined)
|
|
3847
3886
|
recall.current = {
|
|
3848
3887
|
entries: recallSpace,
|
|
3849
3888
|
index: historyFill.index,
|
|
3850
|
-
|
|
3851
|
-
|
|
3889
|
+
// The draft this fill appended to stays reachable: Down walks back to it.
|
|
3890
|
+
savedDraft: current,
|
|
3891
|
+
lastRecalled: joined,
|
|
3852
3892
|
}
|
|
3853
3893
|
historyConsumed()
|
|
3854
3894
|
}, [historyFill, recallSpace, historyConsumed, resetCursorBlink])
|
|
@@ -3905,7 +3945,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
|
|
|
3905
3945
|
return () => {
|
|
3906
3946
|
stdin.read = originalRead
|
|
3907
3947
|
}
|
|
3908
|
-
}, [focusReporting, stdin])
|
|
3948
|
+
}, [focusReporting, inputStdout, stdin])
|
|
3909
3949
|
|
|
3910
3950
|
// Keep the navigation's recall space fresh while browsing state survives
|
|
3911
3951
|
// (new local submissions extend the space; the index stays valid unless
|
|
@@ -3919,7 +3959,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
|
|
|
3919
3959
|
const [completionIndex, setCompletionIndex] = useState(0)
|
|
3920
3960
|
const [dismissedMenuValue, setDismissedMenuValue] = useState<string | undefined>(undefined)
|
|
3921
3961
|
const candidates = completionCandidates(value, descriptors, skills)
|
|
3922
|
-
const slashActive = candidates.length > 0 && value.startsWith('/') && !value.includes(' ') && !value.includes('\n')
|
|
3962
|
+
const slashActive = candidates.length > 0 && value.startsWith('/') && !value.includes(' ') && !value.includes('\n') && !looksLikePathDraft(value)
|
|
3923
3963
|
|
|
3924
3964
|
// @mention token: the last `@word` on the cursor's line before the cursor.
|
|
3925
3965
|
const beforeCursor = value.slice(0, cursor)
|
|
@@ -4035,8 +4075,8 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
|
|
|
4035
4075
|
const requestId = mentionRequestRef.current + 1
|
|
4036
4076
|
mentionRequestRef.current = requestId
|
|
4037
4077
|
if (!active || !mentionActive) {
|
|
4038
|
-
setMentionRows([])
|
|
4039
|
-
setMentionError(undefined)
|
|
4078
|
+
setMentionRows(current => current.length === 0 ? current : [])
|
|
4079
|
+
setMentionError(current => current === undefined ? current : undefined)
|
|
4040
4080
|
return
|
|
4041
4081
|
}
|
|
4042
4082
|
setMentionError(undefined)
|
|
@@ -4059,7 +4099,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
|
|
|
4059
4099
|
clearTimeout(timer)
|
|
4060
4100
|
controller.abort()
|
|
4061
4101
|
}
|
|
4062
|
-
}, [active, mentionActive, mentionToken?.query])
|
|
4102
|
+
}, [active, loadMentions, mentionActive, mentionToken?.query])
|
|
4063
4103
|
|
|
4064
4104
|
// Codex routes keys to the topmost surface first. Completion therefore
|
|
4065
4105
|
// remains available while a turn runs, and Esc dismisses it before the
|
|
@@ -4095,6 +4135,23 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
|
|
|
4095
4135
|
// the App's dynamic budget can reserve it instead of overflowing.
|
|
4096
4136
|
const menuHeightRows = menuActive ? completionMenuRowCount(inputTerminalRows, menuRows.length) : 0
|
|
4097
4137
|
|
|
4138
|
+
/**
|
|
4139
|
+
* What accepting the highlighted completion candidate would insert, or ''
|
|
4140
|
+
* when nothing can be accepted (no rows, or an image row that resolves
|
|
4141
|
+
* asynchronously). The Enter branch uses it to tell a real accept from a
|
|
4142
|
+
* no-op that must fall through to submission.
|
|
4143
|
+
*/
|
|
4144
|
+
const highlightedCompletion = (): string => {
|
|
4145
|
+
if (mentionActive) {
|
|
4146
|
+
const row = rankedMentionRows[completionIndex % Math.max(1, rankedMentionRows.length)]
|
|
4147
|
+
if (row === undefined) return ''
|
|
4148
|
+
if (row.kind === 'file' && row.path !== undefined && looksLikeImagePath(row.path)) return ''
|
|
4149
|
+
return row.label.startsWith('@') ? row.label : `@${row.label}${row.kind === 'directory' ? '/' : ''}`
|
|
4150
|
+
}
|
|
4151
|
+
const candidate = candidates[completionIndex % Math.max(1, candidates.length)]
|
|
4152
|
+
return candidate === undefined ? '' : `${candidate.label} `
|
|
4153
|
+
}
|
|
4154
|
+
|
|
4098
4155
|
/** Accept the highlighted completion-menu candidate into the draft. */
|
|
4099
4156
|
const acceptMenuCandidate = (): void => {
|
|
4100
4157
|
if (mentionActive && mentionToken !== undefined) {
|
|
@@ -4184,6 +4241,18 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
|
|
|
4184
4241
|
preferredColumnRef.current = null
|
|
4185
4242
|
setCompletionIndex(0)
|
|
4186
4243
|
setDismissedMenuValue(undefined)
|
|
4244
|
+
// A file drag (any OS / terminal) often writes the path without
|
|
4245
|
+
// bracketed-paste wrappers, so it lands as ordinary insert. If the whole
|
|
4246
|
+
// draft is one drop path, attach it the same way a copied pathname paste
|
|
4247
|
+
// would.
|
|
4248
|
+
const dropped = parsePastedAttachmentPaths(edit.value)
|
|
4249
|
+
if (dropped.images.length > 0 || dropped.files.length > 0) {
|
|
4250
|
+
valueRef.current = ''
|
|
4251
|
+
cursorRef.current = 0
|
|
4252
|
+
setValue('')
|
|
4253
|
+
setCursor(0)
|
|
4254
|
+
insertDroppedAttachments(dropped.images, dropped.files)
|
|
4255
|
+
}
|
|
4187
4256
|
}
|
|
4188
4257
|
|
|
4189
4258
|
/** Move the cursor without editing; horizontal moves clear the column preference. */
|
|
@@ -4415,8 +4484,19 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
|
|
|
4415
4484
|
// exactly, in which case Enter submits it (typing a full "/effort" and
|
|
4416
4485
|
// pressing return must run the command, not re-accept its own text).
|
|
4417
4486
|
if (menuActive) {
|
|
4487
|
+
// Accepting can be a no-op: the menu is open with no matches yet, the
|
|
4488
|
+
// slash line already spells its candidate, or a mention insertion
|
|
4489
|
+
// repeats the token already in the draft. Enter must reach the submit
|
|
4490
|
+
// path in every one of those cases, or the message cannot be sent.
|
|
4491
|
+
const highlighted = highlightedCompletion()
|
|
4492
|
+
const repeatsToken = mentionActive && mentionToken !== undefined && cursor === liveValue.length
|
|
4493
|
+
&& liveValue.slice(mentionToken.start, cursor) === highlighted
|
|
4494
|
+
// The slash rule is a whole-list exact match, not a highlighted-row
|
|
4495
|
+
// comparison: typing `/mode` once the list is open must run the
|
|
4496
|
+
// command even when the highlight happens to rest on `/model`.
|
|
4418
4497
|
const exactSlash = !mentionActive && candidates.some(candidate => candidate.label === liveValue)
|
|
4419
|
-
|
|
4498
|
+
const acceptNoop = highlighted === '' || repeatsToken || exactSlash
|
|
4499
|
+
if (!acceptNoop) {
|
|
4420
4500
|
acceptMenuCandidate()
|
|
4421
4501
|
return
|
|
4422
4502
|
}
|
|
@@ -4530,7 +4610,7 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
|
|
|
4530
4610
|
if (text === '/copy') {
|
|
4531
4611
|
void copyLastResponse().then(
|
|
4532
4612
|
outcome => notify(outcome),
|
|
4533
|
-
error => notify(
|
|
4613
|
+
error => notify(t('notice.copyFailed', { message: error instanceof Error ? error.message : String(error) }), 'error'),
|
|
4534
4614
|
)
|
|
4535
4615
|
return
|
|
4536
4616
|
}
|
|
@@ -4677,6 +4757,11 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
|
|
|
4677
4757
|
openDelete(text.slice(7).trim())
|
|
4678
4758
|
return
|
|
4679
4759
|
}
|
|
4760
|
+
const slash = slashNameAndArgs(text)
|
|
4761
|
+
if (slash !== undefined && BARE_LOCAL_COMMANDS.has(slash.name) && slash.args !== '') {
|
|
4762
|
+
notify(t('notice.usage.bareCommand', { name: slash.name }), 'warning')
|
|
4763
|
+
return
|
|
4764
|
+
}
|
|
4680
4765
|
// Delivery mode: everything above this point is a local command or a
|
|
4681
4766
|
// panel opener and always runs out of band. A real prompt follows the
|
|
4682
4767
|
// composer's Tab choice — `steer` joins the running turn, the default
|
|
@@ -4706,11 +4791,11 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
|
|
|
4706
4791
|
return
|
|
4707
4792
|
}
|
|
4708
4793
|
if (menuActive && key.upArrow) {
|
|
4709
|
-
setCompletionIndex(index => (index
|
|
4794
|
+
setCompletionIndex(index => stepCompletionIndex(index, -1, menuRows.length))
|
|
4710
4795
|
return
|
|
4711
4796
|
}
|
|
4712
4797
|
if (menuActive && key.downArrow) {
|
|
4713
|
-
setCompletionIndex(index => (index
|
|
4798
|
+
setCompletionIndex(index => stepCompletionIndex(index, 1, menuRows.length))
|
|
4714
4799
|
return
|
|
4715
4800
|
}
|
|
4716
4801
|
// Batched Home/End/Delete/Backspace sequences bypass Ink's one-key parser
|
|
@@ -4803,10 +4888,13 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
|
|
|
4803
4888
|
return
|
|
4804
4889
|
}
|
|
4805
4890
|
if (input !== '' && !key.ctrl && !key.meta) {
|
|
4806
|
-
// Bracketed-paste wrappers arrive as
|
|
4807
|
-
//
|
|
4808
|
-
//
|
|
4809
|
-
//
|
|
4891
|
+
// Bracketed-paste wrappers arrive as escape sequences Ink has stripped
|
|
4892
|
+
// only ONE leading ESC from, so the tail marker still carries its own:
|
|
4893
|
+
// strip both spellings before the payload is read, or a dragged path
|
|
4894
|
+
// reaches the attachment parser with a trailing control byte and fails.
|
|
4895
|
+
// Markers may ride their own chunk or the edges of a content chunk; the
|
|
4896
|
+
// open-paste flag is tracked so a chunk that is exactly LF inserts
|
|
4897
|
+
// instead of submitting.
|
|
4810
4898
|
let text = input
|
|
4811
4899
|
if (text.includes(PASTE_START_MARKER)) {
|
|
4812
4900
|
pasteBracketRef.current = true
|
|
@@ -4821,13 +4909,12 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
|
|
|
4821
4909
|
clearTimeout(timer)
|
|
4822
4910
|
pasteBracketCancelRef.current = undefined
|
|
4823
4911
|
}
|
|
4824
|
-
text = text.replaceAll(PASTE_START_MARKER, '')
|
|
4825
4912
|
}
|
|
4826
4913
|
if (text.includes(PASTE_END_MARKER)) {
|
|
4827
4914
|
pasteBracketRef.current = false
|
|
4828
4915
|
pasteBracketCancelRef.current?.()
|
|
4829
|
-
text = text.replaceAll(PASTE_END_MARKER, '')
|
|
4830
4916
|
}
|
|
4917
|
+
text = stripPasteMarkers(text)
|
|
4831
4918
|
if (text === '') return
|
|
4832
4919
|
if (text.length > 1) {
|
|
4833
4920
|
// A path-list paste splits into images and files; prose falls through
|
|
@@ -4921,7 +5008,9 @@ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch
|
|
|
4921
5008
|
// the reserve from outliving the menu (unmount or inactive handoff).
|
|
4922
5009
|
useEffect(() => {
|
|
4923
5010
|
onMenuRows(menuHeightRows)
|
|
4924
|
-
return () =>
|
|
5011
|
+
return () => {
|
|
5012
|
+
if (menuHeightRows !== 0) onMenuRows(0)
|
|
5013
|
+
}
|
|
4925
5014
|
}, [menuHeightRows, onMenuRows])
|
|
4926
5015
|
// The composer band: the old border's three-row footprint repainted as a
|
|
4927
5016
|
// background-color band (the Codex-style shaded composer strip) — one
|
|
@@ -5386,14 +5475,22 @@ export function App(props: AppProps): ReactElement {
|
|
|
5386
5475
|
const [authorizationDirectory, setAuthorizationDirectory] = useState<ProviderAuthorizationDirectory | undefined>(undefined)
|
|
5387
5476
|
const [authorizationError, setAuthorizationError] = useState<string | undefined>(undefined)
|
|
5388
5477
|
const [modelLoadEpoch, setModelLoadEpoch] = useState(0)
|
|
5478
|
+
/** Bumped when /effort's catalog lookup must be ignored (panel closed or superseded). */
|
|
5479
|
+
const effortLookupEpoch = useRef(0)
|
|
5389
5480
|
const [notice, setNotice] = useState<{ text: string; tone: NoticeTone } | undefined>(undefined)
|
|
5390
5481
|
const notify = useCallback((text: string, tone: NoticeTone = 'info'): void => {
|
|
5391
5482
|
setNotice({ text, tone })
|
|
5392
5483
|
}, [])
|
|
5393
5484
|
|
|
5485
|
+
const {
|
|
5486
|
+
loadModels,
|
|
5487
|
+
loadModelProviders,
|
|
5488
|
+
loadProviderAuthorizations,
|
|
5489
|
+
onBridgeReady,
|
|
5490
|
+
} = props
|
|
5394
5491
|
useEffect(() => {
|
|
5395
|
-
|
|
5396
|
-
}, [])
|
|
5492
|
+
onBridgeReady({ notify })
|
|
5493
|
+
}, [notify, onBridgeReady])
|
|
5397
5494
|
useEffect(() => {
|
|
5398
5495
|
if (!modelOpen) return
|
|
5399
5496
|
let cancelled = false
|
|
@@ -5402,7 +5499,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
5402
5499
|
// Enter the promise chain before invoking the loader so a provider that
|
|
5403
5500
|
// throws synchronously becomes an in-panel error instead of escaping the
|
|
5404
5501
|
// React effect and tearing down Ink.
|
|
5405
|
-
Promise.resolve().then(() =>
|
|
5502
|
+
Promise.resolve().then(() => loadModels()).then((loaded) => {
|
|
5406
5503
|
if (!cancelled) setDirectory(loaded)
|
|
5407
5504
|
}, (error: unknown) => {
|
|
5408
5505
|
if (!cancelled) setModelError(error instanceof Error ? error.message : String(error))
|
|
@@ -5410,13 +5507,13 @@ export function App(props: AppProps): ReactElement {
|
|
|
5410
5507
|
return () => {
|
|
5411
5508
|
cancelled = true
|
|
5412
5509
|
}
|
|
5413
|
-
}, [modelOpen, modelLoadEpoch
|
|
5510
|
+
}, [loadModels, modelOpen, modelLoadEpoch])
|
|
5414
5511
|
useEffect(() => {
|
|
5415
|
-
if (!modelOpen ||
|
|
5512
|
+
if (!modelOpen || loadModelProviders === undefined) return
|
|
5416
5513
|
let cancelled = false
|
|
5417
5514
|
setProviderDirectory(undefined)
|
|
5418
5515
|
setProviderError(undefined)
|
|
5419
|
-
Promise.resolve().then(() =>
|
|
5516
|
+
Promise.resolve().then(() => loadModelProviders()).then((loaded) => {
|
|
5420
5517
|
if (!cancelled) setProviderDirectory(loaded)
|
|
5421
5518
|
}, (error: unknown) => {
|
|
5422
5519
|
if (!cancelled) setProviderError(error instanceof Error ? error.message : String(error))
|
|
@@ -5424,13 +5521,13 @@ export function App(props: AppProps): ReactElement {
|
|
|
5424
5521
|
return () => {
|
|
5425
5522
|
cancelled = true
|
|
5426
5523
|
}
|
|
5427
|
-
}, [modelOpen, modelLoadEpoch
|
|
5524
|
+
}, [loadModelProviders, modelOpen, modelLoadEpoch])
|
|
5428
5525
|
useEffect(() => {
|
|
5429
|
-
if (!modelOpen ||
|
|
5526
|
+
if (!modelOpen || loadProviderAuthorizations === undefined) return
|
|
5430
5527
|
let cancelled = false
|
|
5431
5528
|
setAuthorizationDirectory(undefined)
|
|
5432
5529
|
setAuthorizationError(undefined)
|
|
5433
|
-
Promise.resolve().then(() =>
|
|
5530
|
+
Promise.resolve().then(() => loadProviderAuthorizations()).then((loaded) => {
|
|
5434
5531
|
if (!cancelled) setAuthorizationDirectory(loaded)
|
|
5435
5532
|
}, (error: unknown) => {
|
|
5436
5533
|
if (!cancelled) setAuthorizationError(error instanceof Error ? error.message : String(error))
|
|
@@ -5438,7 +5535,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
5438
5535
|
return () => {
|
|
5439
5536
|
cancelled = true
|
|
5440
5537
|
}
|
|
5441
|
-
}, [modelOpen, modelLoadEpoch
|
|
5538
|
+
}, [loadProviderAuthorizations, modelOpen, modelLoadEpoch])
|
|
5442
5539
|
useEffect(() => {
|
|
5443
5540
|
const subscribe = props.subscribeModelProviders
|
|
5444
5541
|
if (!modelOpen || subscribe === undefined) return
|
|
@@ -5482,6 +5579,8 @@ export function App(props: AppProps): ReactElement {
|
|
|
5482
5579
|
const [pluginOpen, setPluginOpen] = useState(false)
|
|
5483
5580
|
const [pluginQuery, setPluginQuery] = useState('')
|
|
5484
5581
|
const [updateOpen, setUpdateOpen] = useState(false)
|
|
5582
|
+
const [updateApplying, setUpdateApplying] = useState(false)
|
|
5583
|
+
useEffect(() => subscribeUpdateApplyRunning(setUpdateApplying), [])
|
|
5485
5584
|
const [scheduleOpen, setScheduleOpen] = useState(false)
|
|
5486
5585
|
const [jobsOpen, setJobsOpen] = useState(false)
|
|
5487
5586
|
const [statuslineOpen, setStatuslineOpen] = useState(false)
|
|
@@ -5493,7 +5592,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
5493
5592
|
const [subagentOpen, setSubagentOpen] = useState(false)
|
|
5494
5593
|
const [todosOpen, setTodosOpen] = useState(false)
|
|
5495
5594
|
const [usageOpen, setUsageOpen] = useState(false)
|
|
5496
|
-
/** /delete state:
|
|
5595
|
+
/** /delete state: dedicated picker mode plus an optional pre-armed row id. */
|
|
5497
5596
|
const [resumeDelete, setResumeDelete] = useState<{ mode: boolean; id?: string }>({ mode: false })
|
|
5498
5597
|
/** The row id awaiting y/n in the COMPOSER (codex delete confirm): the
|
|
5499
5598
|
* composer takes the keys, the resume panel yields until it settles. */
|
|
@@ -5506,11 +5605,12 @@ export function App(props: AppProps): ReactElement {
|
|
|
5506
5605
|
const cancelDelete = useCallback((): void => {
|
|
5507
5606
|
setDeleteConfirmId(undefined)
|
|
5508
5607
|
}, [])
|
|
5608
|
+
const deleteSession = props.deleteSession
|
|
5509
5609
|
const confirmDelete = useCallback((): void => {
|
|
5510
5610
|
const id = deleteConfirmId
|
|
5511
5611
|
if (id === undefined) return
|
|
5512
5612
|
setDeleteConfirmId(undefined)
|
|
5513
|
-
void
|
|
5613
|
+
void deleteSession(id).then(outcome => {
|
|
5514
5614
|
notify(outcome)
|
|
5515
5615
|
// Keep the picker open and reload: a successful deletion must vanish
|
|
5516
5616
|
// from the list immediately, not look like a no-op.
|
|
@@ -5518,7 +5618,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
5518
5618
|
}, (reason: unknown) => {
|
|
5519
5619
|
notify(t('notice.deleteFailed', { message: reason instanceof Error ? reason.message : String(reason) }), 'error')
|
|
5520
5620
|
})
|
|
5521
|
-
}, [
|
|
5621
|
+
}, [deleteSession, deleteConfirmId, notify])
|
|
5522
5622
|
/** The /history panel's accepted entry: text plus its recall-space index. */
|
|
5523
5623
|
const [historyFill, setHistoryFill] = useState<{ text: string; index: number } | undefined>(undefined)
|
|
5524
5624
|
/** Submissions recorded in this process (Codex local history; persistent file stays in the runner). */
|
|
@@ -5557,40 +5657,69 @@ export function App(props: AppProps): ReactElement {
|
|
|
5557
5657
|
const agentRows = useSyncExternalStore(subscribeSubagents, readAgentRows)
|
|
5558
5658
|
const approvalPending = approvalSnapshot.pending !== undefined
|
|
5559
5659
|
const questionPending = questionSnapshot.pending !== undefined
|
|
5560
|
-
|
|
5660
|
+
/**
|
|
5661
|
+
* Every keyboard-owning surface that is mutually exclusive with the composer,
|
|
5662
|
+
* in precedence order. This ONE list drives the composer gate, the transcript
|
|
5663
|
+
* visibility, the frozen band's hint, and the hand-off when a human approval
|
|
5664
|
+
* or question arrives, so a panel cannot be wired into one of them and
|
|
5665
|
+
* forgotten in the others.
|
|
5666
|
+
*/
|
|
5667
|
+
const panelSurfaces: readonly { readonly hint: string; readonly open: boolean; readonly close: () => void }[] = [
|
|
5668
|
+
{ hint: 'the diff review', open: diffView !== undefined, close: () => setDiffView(undefined) },
|
|
5669
|
+
{ hint: 'the review picker', open: reviewPickerOpen, close: () => setReviewPickerOpen(false) },
|
|
5670
|
+
{
|
|
5671
|
+
hint: '/model',
|
|
5672
|
+
open: modelOpen,
|
|
5673
|
+
close: () => {
|
|
5674
|
+
setModelOpen(false)
|
|
5675
|
+
setProviderOpen(false)
|
|
5676
|
+
setProviderAction(undefined)
|
|
5677
|
+
setEffortFor(undefined)
|
|
5678
|
+
},
|
|
5679
|
+
},
|
|
5680
|
+
{ hint: '/help', open: helpOpen, close: () => setHelpOpen(false) },
|
|
5681
|
+
{ hint: '/mode', open: modeOpen, close: () => setModeOpen(false) },
|
|
5682
|
+
{ hint: '/permission', open: permissionOpen, close: () => setPermissionOpen(false) },
|
|
5683
|
+
{ hint: '/resume', open: resumeOpen, close: () => setResumeOpen(false) },
|
|
5684
|
+
{ hint: '/search', open: searchOpen, close: () => setSearchOpen(false) },
|
|
5685
|
+
{ hint: '/plugin', open: pluginOpen, close: () => setPluginOpen(false) },
|
|
5686
|
+
{ hint: '/update', open: updateOpen, close: () => setUpdateOpen(false) },
|
|
5687
|
+
{ hint: '/schedule', open: scheduleOpen, close: () => setScheduleOpen(false) },
|
|
5688
|
+
{ hint: '/jobs', open: jobsOpen, close: () => setJobsOpen(false) },
|
|
5689
|
+
{ hint: '/statusline', open: statuslineOpen, close: () => setStatuslineOpen(false) },
|
|
5690
|
+
{ hint: '/theme', open: themeOpen, close: () => setThemeOpen(false) },
|
|
5691
|
+
{ hint: '/language', open: languageOpen, close: () => setLanguageOpen(false) },
|
|
5692
|
+
{ hint: '/history', open: historyOpen, close: () => setHistoryOpen(false) },
|
|
5693
|
+
{ hint: '/queue', open: queueOpen, close: () => setQueueOpen(false) },
|
|
5694
|
+
{ hint: '/agents', open: agentsOpen, close: () => setAgentsOpen(false) },
|
|
5695
|
+
{ hint: '/subagent', open: subagentOpen, close: () => setSubagentOpen(false) },
|
|
5696
|
+
{ hint: '/todos', open: todosOpen, close: () => setTodosOpen(false) },
|
|
5697
|
+
{ hint: '/usage', open: usageOpen, close: () => setUsageOpen(false) },
|
|
5698
|
+
]
|
|
5699
|
+
const panelSurfacesRef = useRef(panelSurfaces)
|
|
5700
|
+
panelSurfacesRef.current = panelSurfaces
|
|
5701
|
+
const openPanel = panelSurfaces.find(surface => surface.open)
|
|
5702
|
+
// The Ctrl+O inspector is the one surface the composer already yields to
|
|
5703
|
+
// through verboseOpen; it rides the same gate without a panel row.
|
|
5704
|
+
const inspectorVisible = verboseOpen && !approvalPending && !questionPending
|
|
5705
|
+
const modalVisible = openPanel !== undefined || inspectorVisible || approvalPending || questionPending
|
|
5561
5706
|
// While a deletion waits for y/n, the composer takes the keys (the resume
|
|
5562
5707
|
// panel yields): the confirm is typed IN the input box, not as an invisible
|
|
5563
5708
|
// panel keypress.
|
|
5564
5709
|
const inputActive = deleteConfirmId !== undefined
|
|
5565
5710
|
? !approvalPending && !questionPending
|
|
5566
|
-
: !
|
|
5567
|
-
const transcriptVisible = !
|
|
5711
|
+
: !modalVisible
|
|
5712
|
+
const transcriptVisible = !modalVisible
|
|
5568
5713
|
|
|
5569
|
-
// Human questions outrank local inspectors. Close
|
|
5570
|
-
// of leaving
|
|
5714
|
+
// Human questions outrank local inspectors. Close every open surface instead
|
|
5715
|
+
// of leaving it visible but keyboard-locked behind the approval.
|
|
5571
5716
|
useEffect(() => {
|
|
5572
5717
|
if (!approvalPending && !questionPending) return
|
|
5573
|
-
|
|
5574
|
-
|
|
5575
|
-
|
|
5576
|
-
setEffortFor(undefined)
|
|
5577
|
-
setHelpOpen(false)
|
|
5578
|
-
setModeOpen(false)
|
|
5579
|
-
setPermissionOpen(false)
|
|
5580
|
-
setResumeOpen(false)
|
|
5581
|
-
setPluginOpen(false)
|
|
5582
|
-
setUpdateOpen(false)
|
|
5583
|
-
setScheduleOpen(false)
|
|
5584
|
-
setStatuslineOpen(false)
|
|
5585
|
-
setThemeOpen(false)
|
|
5586
|
-
setHistoryOpen(false)
|
|
5587
|
-
setQueueOpen(false)
|
|
5588
|
-
setAgentsOpen(false)
|
|
5589
|
-
setSubagentOpen(false)
|
|
5590
|
-
setTodosOpen(false)
|
|
5718
|
+
for (const surface of panelSurfacesRef.current) {
|
|
5719
|
+
if (surface.open) surface.close()
|
|
5720
|
+
}
|
|
5591
5721
|
setDeleteConfirmId(undefined)
|
|
5592
5722
|
setVerboseOpen(false)
|
|
5593
|
-
setDiffView(undefined)
|
|
5594
5723
|
}, [approvalPending, questionPending])
|
|
5595
5724
|
|
|
5596
5725
|
// Append-only transcript: everything up to the first still-mutable entry
|
|
@@ -5774,8 +5903,6 @@ export function App(props: AppProps): ReactElement {
|
|
|
5774
5903
|
: visibleLiveLines.slice(-liveAudit.allocation.live)
|
|
5775
5904
|
const auditedReasoningRows = liveAudit.allocation.reasoning
|
|
5776
5905
|
const auditedAnswerRows = liveAudit.allocation.answer
|
|
5777
|
-
const inspectorVisible = verboseOpen && !approvalPending && !questionPending
|
|
5778
|
-
const modalVisible = modelOpen || helpOpen || modeOpen || permissionOpen || resumeOpen || pluginOpen || updateOpen || scheduleOpen || jobsOpen || statuslineOpen || themeOpen || languageOpen || historyOpen || queueOpen || agentsOpen || subagentOpen || todosOpen || usageOpen || inspectorVisible || diffView !== undefined || reviewPickerOpen || approvalPending || questionPending
|
|
5779
5906
|
// The surface that currently owns the keyboard, named in the frozen band:
|
|
5780
5907
|
// an empty composer under a panel must not advertise typing it cannot
|
|
5781
5908
|
// accept — every key actually feeds the panel (which may or may not
|
|
@@ -5784,65 +5911,43 @@ export function App(props: AppProps): ReactElement {
|
|
|
5784
5911
|
? 'the approval prompt'
|
|
5785
5912
|
: questionPending
|
|
5786
5913
|
? 'the question'
|
|
5787
|
-
:
|
|
5788
|
-
|
|
5789
|
-
|
|
5790
|
-
|
|
5791
|
-
|
|
5792
|
-
|
|
5793
|
-
|
|
5794
|
-
|
|
5795
|
-
|
|
5796
|
-
|
|
5797
|
-
|
|
5798
|
-
? '/permission'
|
|
5799
|
-
: resumeOpen
|
|
5800
|
-
? '/resume'
|
|
5801
|
-
: pluginOpen
|
|
5802
|
-
? '/plugin'
|
|
5803
|
-
: updateOpen
|
|
5804
|
-
? '/update'
|
|
5805
|
-
: scheduleOpen
|
|
5806
|
-
? '/schedule'
|
|
5807
|
-
: jobsOpen
|
|
5808
|
-
? '/jobs'
|
|
5809
|
-
: statuslineOpen
|
|
5810
|
-
? '/statusline'
|
|
5811
|
-
: themeOpen
|
|
5812
|
-
? '/theme'
|
|
5813
|
-
: languageOpen
|
|
5814
|
-
? '/language'
|
|
5815
|
-
: historyOpen
|
|
5816
|
-
? '/history'
|
|
5817
|
-
: agentsOpen
|
|
5818
|
-
? '/agents'
|
|
5819
|
-
: subagentOpen
|
|
5820
|
-
? '/subagent'
|
|
5821
|
-
: todosOpen
|
|
5822
|
-
? '/todos'
|
|
5823
|
-
: usageOpen
|
|
5824
|
-
? '/usage'
|
|
5825
|
-
: inspectorVisible
|
|
5826
|
-
? 'history details'
|
|
5827
|
-
: undefined
|
|
5914
|
+
: openPanel?.hint ?? (inspectorVisible ? 'history details' : undefined)
|
|
5915
|
+
// One way out of every panel, matching Esc: while a panel owns the keys,
|
|
5916
|
+
// Ctrl+C closes it instead of silently doing nothing. The composer keeps its
|
|
5917
|
+
// own three states (interrupt the turn / clear the draft / quit) whenever no
|
|
5918
|
+
// panel is open, and the approval and question bars keep theirs.
|
|
5919
|
+
useStableInput((input, key) => {
|
|
5920
|
+
if (!(key.ctrl && input === 'c')) return
|
|
5921
|
+
if (approvalPending || questionPending || deleteConfirmId !== undefined) return
|
|
5922
|
+
if (openPanel !== undefined) openPanel.close()
|
|
5923
|
+
else if (inspectorVisible) setVerboseOpen(false)
|
|
5924
|
+
}, true)
|
|
5828
5925
|
const frozenHint = keyboardOwner === undefined
|
|
5829
5926
|
? undefined
|
|
5830
|
-
:
|
|
5927
|
+
: t('frozen.keysGoTo', {
|
|
5928
|
+
owner: keyboardOwner,
|
|
5929
|
+
action: approvalPending
|
|
5930
|
+
? t('frozen.action.rejects')
|
|
5931
|
+
: questionPending
|
|
5932
|
+
? t('frozen.action.cancels')
|
|
5933
|
+
: updateApplying && updateOpen
|
|
5934
|
+
? t('frozen.action.waits')
|
|
5935
|
+
: t('frozen.action.closes'),
|
|
5936
|
+
})
|
|
5831
5937
|
const closeInspector = useCallback((): void => {
|
|
5832
5938
|
setVerboseOpen(false)
|
|
5833
5939
|
}, [])
|
|
5834
|
-
const refreshScreen = (): void => {
|
|
5835
|
-
//
|
|
5836
|
-
//
|
|
5837
|
-
//
|
|
5838
|
-
|
|
5839
|
-
// stale-position flicker where the screen keeps redrawing.
|
|
5940
|
+
const refreshScreen = useCallback((opts?: { wipeScrollback?: boolean }): void => {
|
|
5941
|
+
// Resize / Ctrl+L wipe screen AND scrollback. A history-cap trim remounts
|
|
5942
|
+
// Static at the current width, so native scrollback must stay — the user
|
|
5943
|
+
// may be reading messages above the fold.
|
|
5944
|
+
const clear = opts?.wipeScrollback === false ? TRIM_REFLOW_CLEAR : RESIZE_REFLOW_CLEAR
|
|
5840
5945
|
if (appStdout !== undefined) {
|
|
5841
5946
|
synchronizedReplayPending.current = true
|
|
5842
|
-
appStdout.write(SYNCHRONIZED_UPDATE_BEGIN +
|
|
5947
|
+
appStdout.write(SYNCHRONIZED_UPDATE_BEGIN + clear)
|
|
5843
5948
|
}
|
|
5844
5949
|
setRefreshEpoch(epoch => epoch + 1)
|
|
5845
|
-
}
|
|
5950
|
+
}, [appStdout])
|
|
5846
5951
|
const applyRainbow = (seed?: number): void => {
|
|
5847
5952
|
// Replace the memoized roll, then setTheme so getPalette() and the
|
|
5848
5953
|
// painters pick the new values; persist rainbow as the active theme
|
|
@@ -5871,8 +5976,8 @@ export function App(props: AppProps): ReactElement {
|
|
|
5871
5976
|
const settledNeedsTrim = settledRowsCache.current?.needsTrim === true
|
|
5872
5977
|
useEffect(() => {
|
|
5873
5978
|
if (!settledNeedsTrim || busy || streamingActive) return
|
|
5874
|
-
refreshScreen()
|
|
5875
|
-
}, [
|
|
5979
|
+
refreshScreen({ wipeScrollback: false })
|
|
5980
|
+
}, [busy, refreshScreen, settledNeedsTrim, streamingActive])
|
|
5876
5981
|
|
|
5877
5982
|
const sessionHasImages = useMemo(() => view.entries.some(entry =>
|
|
5878
5983
|
(entry.kind === 'user' || entry.kind === 'pending') && (entry.images?.length ?? 0) > 0), [view.entries])
|
|
@@ -5902,6 +6007,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
5902
6007
|
setModelLoadEpoch(epoch => epoch + 1)
|
|
5903
6008
|
}
|
|
5904
6009
|
const closeModelSurface = (): void => {
|
|
6010
|
+
effortLookupEpoch.current += 1
|
|
5905
6011
|
setModelOpen(false)
|
|
5906
6012
|
setProviderOpen(false)
|
|
5907
6013
|
setProviderAction(undefined)
|
|
@@ -6130,6 +6236,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
6130
6236
|
continuationPrefix: ' ',
|
|
6131
6237
|
dim: true,
|
|
6132
6238
|
maxRows: auditedReasoningRows,
|
|
6239
|
+
columns: Math.max(1, terminalColumns - 2),
|
|
6133
6240
|
})
|
|
6134
6241
|
// The collapsed marker shimmers only while reasoning streams
|
|
6135
6242
|
// alone: once answer text flows, a periodically re-rendered
|
|
@@ -6146,6 +6253,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
6146
6253
|
continuationPrefix: ' ',
|
|
6147
6254
|
dim: true,
|
|
6148
6255
|
maxRows: auditedReasoningRows,
|
|
6256
|
+
columns: Math.max(1, terminalColumns - 2),
|
|
6149
6257
|
})
|
|
6150
6258
|
: undefined,
|
|
6151
6259
|
view.streaming !== '' && auditedAnswerRows > 0
|
|
@@ -6153,7 +6261,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
6153
6261
|
StreamTail,
|
|
6154
6262
|
// The same two-column gutter as settled replies: streamed text
|
|
6155
6263
|
// lands exactly where the assembled message will render.
|
|
6156
|
-
{ text: view.streaming, dim: false, maxRows: auditedAnswerRows, prefix: ' ' },
|
|
6264
|
+
{ text: view.streaming, dim: false, maxRows: auditedAnswerRows, prefix: ' ', columns: Math.max(1, terminalColumns - 2) },
|
|
6157
6265
|
busy ? createElement(Caret, { animated: animations }) : undefined,
|
|
6158
6266
|
)
|
|
6159
6267
|
: undefined,
|
|
@@ -6220,6 +6328,8 @@ export function App(props: AppProps): ReactElement {
|
|
|
6220
6328
|
? createElement(MemoVerbosePanel, {
|
|
6221
6329
|
entries: view.entries,
|
|
6222
6330
|
onClose: closeInspector,
|
|
6331
|
+
columns: terminalColumns,
|
|
6332
|
+
rows: terminalRows,
|
|
6223
6333
|
})
|
|
6224
6334
|
: undefined,
|
|
6225
6335
|
modeOpen && !approvalPending && !questionPending
|
|
@@ -6256,11 +6366,18 @@ export function App(props: AppProps): ReactElement {
|
|
|
6256
6366
|
currentCwd: props.workspaceRoot,
|
|
6257
6367
|
load: props.loadSessions,
|
|
6258
6368
|
readTranscript: props.loadSessionTranscript,
|
|
6259
|
-
requestDelete,
|
|
6369
|
+
requestDelete: resumeDelete.mode ? requestDelete : undefined,
|
|
6260
6370
|
deleteConfirmId,
|
|
6261
6371
|
reloadToken: deleteReloadToken,
|
|
6262
6372
|
deleteMode: resumeDelete.mode,
|
|
6263
|
-
|
|
6373
|
+
presetId: resumeDelete.id,
|
|
6374
|
+
select: (row: SessionRow) => {
|
|
6375
|
+
// Defense in depth: the dedicated delete picker must never turn a
|
|
6376
|
+
// selection into a session switch, even if its key routing regresses.
|
|
6377
|
+
if (resumeDelete.mode) return
|
|
6378
|
+
props.switchSession(row)
|
|
6379
|
+
setResumeOpen(false)
|
|
6380
|
+
},
|
|
6264
6381
|
close: () => setResumeOpen(false),
|
|
6265
6382
|
})
|
|
6266
6383
|
: undefined,
|
|
@@ -6425,6 +6542,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
6425
6542
|
interrupt: props.interrupt,
|
|
6426
6543
|
quit: props.quit,
|
|
6427
6544
|
openModel: () => {
|
|
6545
|
+
effortLookupEpoch.current += 1
|
|
6428
6546
|
setDirectory(undefined)
|
|
6429
6547
|
setModelError(undefined)
|
|
6430
6548
|
setProviderDirectory(undefined)
|
|
@@ -6447,7 +6565,9 @@ export function App(props: AppProps): ReactElement {
|
|
|
6447
6565
|
// never as "the model has no efforts" — the adapter advertises
|
|
6448
6566
|
// levels for every deepseek model, so "no efforts" is almost
|
|
6449
6567
|
// always a failed resolveModelInfo, not a fact.
|
|
6568
|
+
const epoch = ++effortLookupEpoch.current
|
|
6450
6569
|
void props.loadModels().then((loaded) => {
|
|
6570
|
+
if (epoch !== effortLookupEpoch.current) return
|
|
6451
6571
|
const [provider, model] = modelLabel.split('/')
|
|
6452
6572
|
const row = loaded.rows.find(candidate => candidate.provider === provider && candidate.model === model)
|
|
6453
6573
|
?? loaded.rows.find(candidate => candidate.model === model && candidate.reasoning !== undefined)
|
|
@@ -6507,7 +6627,9 @@ export function App(props: AppProps): ReactElement {
|
|
|
6507
6627
|
openDelete: (id?: string) => {
|
|
6508
6628
|
const armed = id === undefined || id === '' ? undefined : id
|
|
6509
6629
|
setResumeDelete({ mode: true, ...armed === undefined ? {} : { id: armed } })
|
|
6510
|
-
|
|
6630
|
+
// Confirm after the picker resolves the id against the listing —
|
|
6631
|
+
// the argument may be a suffix of the displayed id, not the row key.
|
|
6632
|
+
setDeleteConfirmId(undefined)
|
|
6511
6633
|
setResumeOpen(true)
|
|
6512
6634
|
},
|
|
6513
6635
|
openDiff: (argument: string) => {
|