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/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 { DSH_CODE_VERSION, dshKernelVersion } from './version.ts'
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 StreamTail({ text, dim, maxRows, prefix = '', continuationPrefix = prefix, children }: {
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
- // The final extra column keeps a caret from wrapping onto an unbudgeted
636
- // row. Both prefixes participate because every physical row repeats its
637
- // hanging indent.
638
- const prefixColumns = Math.max(visibleColumns(prefix), visibleColumns(continuationPrefix))
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 = `DeepSeek Harness · v${DSH_CODE_VERSION}`
898
+ const title = headerBrandTitle()
869
899
  const slogan = 'Into the Unknown 探索未至之境'
870
- const hint = resumed ? 'resumed · /help · Esc interrupt' : '/help · Esc interrupt · Ctrl+C quit'
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(() => layoutStatusBar(facts, stats, Math.max(8, columns - 2), {
1305
- busy,
1306
- items,
1307
- // Match the composer content budget: border + horizontal padding are
1308
- // already excluded, and layoutStatusBar shrinks this ceiling as needed.
1309
- contextWidth: Math.max(5, columns - 6),
1310
- }), [
1311
- facts.model,
1312
- facts.mode,
1313
- facts.cwd,
1314
- facts.branch,
1315
- facts.sessionId,
1316
- facts.title,
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 }: { entries: readonly TranscriptEntry[]; onClose: () => void }): ReactElement {
3331
- const stdout = useStdout().stdout
3332
- const columns = stdout?.columns ?? 80
3333
- const rows = stdout?.rows ?? 30
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, visibleScroll)
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 /resume picker in delete mode, optionally pre-armed on one id. */
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: place the sanitized text at
3832
- // the end of the composer and resume recall from that entry.
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
- draftImagesRef.current = []
3837
- setDraftImages([])
3838
- draftFilesRef.current = []
3839
- setDraftFiles([])
3840
- valueRef.current = safe
3841
- cursorRef.current = safe.length
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
- savedDraft: safe,
3851
- lastRecalled: safe,
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
- if (!exactSlash) {
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(`copy failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
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 + menuRows.length - 1) % menuRows.length)
4794
+ setCompletionIndex(index => stepCompletionIndex(index, -1, menuRows.length))
4710
4795
  return
4711
4796
  }
4712
4797
  if (menuActive && key.downArrow) {
4713
- setCompletionIndex(index => (index + 1) % menuRows.length)
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 unknown escape sequences stripped
4807
- // of their ESC. Markers may ride their own chunk or the edges of a
4808
- // content chunk; strip every occurrence and track the open-paste flag
4809
- // so a chunk that is exactly LF inserts instead of submitting.
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 () => onMenuRows(0)
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
- props.onBridgeReady({ notify })
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(() => props.loadModels()).then((loaded) => {
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, props.loadModels])
5510
+ }, [loadModels, modelOpen, modelLoadEpoch])
5414
5511
  useEffect(() => {
5415
- if (!modelOpen || props.loadModelProviders === undefined) return
5512
+ if (!modelOpen || loadModelProviders === undefined) return
5416
5513
  let cancelled = false
5417
5514
  setProviderDirectory(undefined)
5418
5515
  setProviderError(undefined)
5419
- Promise.resolve().then(() => props.loadModelProviders!()).then((loaded) => {
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, props.loadModelProviders])
5524
+ }, [loadModelProviders, modelOpen, modelLoadEpoch])
5428
5525
  useEffect(() => {
5429
- if (!modelOpen || props.loadProviderAuthorizations === undefined) return
5526
+ if (!modelOpen || loadProviderAuthorizations === undefined) return
5430
5527
  let cancelled = false
5431
5528
  setAuthorizationDirectory(undefined)
5432
5529
  setAuthorizationError(undefined)
5433
- Promise.resolve().then(() => props.loadProviderAuthorizations!()).then((loaded) => {
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, props.loadProviderAuthorizations])
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: delete-mode hint plus an optional pre-armed row id. */
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 props.deleteSession(id).then(outcome => {
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
- }, [deleteConfirmId, props.deleteSession, notify])
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
- // While any modal owns the keys, the prompt box passes everything through.
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
- : !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !updateOpen && !scheduleOpen && !jobsOpen && !statuslineOpen && !themeOpen && !languageOpen && !historyOpen && !queueOpen && !agentsOpen && !subagentOpen && !todosOpen && !usageOpen && !verboseOpen && diffView === undefined && !reviewPickerOpen && !approvalPending && !questionPending
5567
- const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !updateOpen && !scheduleOpen && !jobsOpen && !statuslineOpen && !themeOpen && !languageOpen && !historyOpen && !queueOpen && !agentsOpen && !subagentOpen && !todosOpen && !usageOpen && !verboseOpen && diffView === undefined && !reviewPickerOpen && !approvalPending && !questionPending
5711
+ : !modalVisible
5712
+ const transcriptVisible = !modalVisible
5568
5713
 
5569
- // Human questions outrank local inspectors. Close the lower modal instead
5570
- // of leaving an approval/question visible but keyboard-locked behind it.
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
- setModelOpen(false)
5574
- setProviderOpen(false)
5575
- setProviderAction(undefined)
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
- : diffView !== undefined
5788
- ? 'the diff review'
5789
- : reviewPickerOpen
5790
- ? 'the review picker'
5791
- : modelOpen
5792
- ? '/model'
5793
- : helpOpen
5794
- ? '/help'
5795
- : modeOpen
5796
- ? '/mode'
5797
- : permissionOpen
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
- : `keys go to ${keyboardOwner} · esc ${approvalPending ? 'rejects' : questionPending ? 'cancels' : 'closes'}`
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
- // Same source-backed clear the resize path uses: reset the scroll region
5836
- // (`\x1b[r`) before wiping screen AND scrollback, then home the cursor.
5837
- // A bare `\x1b[2J\x1b[3J\x1b[H` leaves a previously set scroll region in
5838
- // place, so Ink's next repaint positions against stale bounds — the
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 + RESIZE_REFLOW_CLEAR)
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
- }, [settledNeedsTrim, busy, streamingActive])
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
- select: (row: SessionRow) => { props.switchSession(row); setResumeOpen(false) },
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
- setDeleteConfirmId(armed)
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) => {