dsh-code 1.0.6 → 1.0.7

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
@@ -34,10 +34,14 @@ import {
34
34
  type ThemeName,
35
35
  } from './theme.ts'
36
36
  import { ThemePanel } from './theme-panel.ts'
37
+ import { UpdatePanel } from './update-panel.ts'
38
+ import type { LauncherUpdateStatus } from './update.ts'
37
39
  import { WHALE_GLYPH, WHALE_GLYPH_COLUMNS } from './whale-glyph.ts'
38
40
  import { DSH_CODE_VERSION, dshKernelVersion } from './version.ts'
39
41
  import type { TranscriptStore } from './store.ts'
42
+ import { DEFAULT_TERMINAL_TITLE, sanitizeTerminalTitle, terminalTitleSequence, useTerminalTitle } from './terminal-title.ts'
40
43
  import { settledEntryCount, type TranscriptEntry } from './render/projection.ts'
44
+ import { imeCursorRowsUp, useImeCursorAnchor } from './render/ime-cursor.ts'
41
45
  import { type MdSegment, visibleColumns } from './render/markdown.ts'
42
46
  import {
43
47
  busyChaseFrame,
@@ -80,7 +84,7 @@ import type { QuestionSnapshot, QuestionStore } from './questions.ts'
80
84
  import type { SkillsView, SkillRow } from './skills.ts'
81
85
  import { isPathLikeMentionQuery, type MentionCandidate } from './mentions.ts'
82
86
  import type { SubagentFeedView, SubagentRow } from './subagents.ts'
83
- import { AgentsPanel, EffortPanel, HistoryPanel, JobsPanel, ModePanel, PermissionPanel, PluginPanel, ResumePanel, StatuslinePanel, runClock, SubagentPanel, type JobRow } from './kernel-panels.ts'
87
+ import { AgentsPanel, editQuery, EffortPanel, HistoryPanel, JobsPanel, ModePanel, PermissionPanel, PluginPanel, ResumePanel, SchedulePanel, StatuslinePanel, runClock, SubagentPanel, type JobRow } from './kernel-panels.ts'
84
88
  import type { PresetRow } from './presets.ts'
85
89
  import type { PermissionRow } from './permissions.ts'
86
90
  import type { PluginRow } from './plugin-inventory.ts'
@@ -230,7 +234,9 @@ const LOCAL_COMMANDS = [
230
234
  { label: '/fork', description: 'fork at the latest completed turn (/fork [event-seq])' },
231
235
  { label: '/resume', description: 'browse or switch root sessions (/resume [id|prefix])' },
232
236
  { label: '/plugin', description: 'inspect the live plugin composition' },
237
+ { label: '/update', description: 'update dsh-code, the harness host, and profile plugins in one aligned step' },
233
238
  { label: '/jobs', description: 'inspect background jobs' },
239
+ { label: '/schedule', description: 'inspect active reminders (created through schedule tools)' },
234
240
  { label: '/statusline', description: 'customize the status line items' },
235
241
  { label: '/theme', description: 'switch the color theme' },
236
242
  { label: '/animation', description: 'toggle timed animations (/animation [on|off])' },
@@ -358,8 +364,10 @@ export interface AppProps {
358
364
  logoutProviderAuthorization?(row: ProviderAuthorizationRow): Promise<void>
359
365
  openAuthorizationUrl?(url: string): boolean
360
366
  copyTextValue?(text: string): Promise<void>
361
- /** Cycle to the next permission preset (Shift+Tab); returns the new label. */
362
- cyclePermission(): string
367
+ /** Cycle to the next mode station (Shift+Tab): a permission preset or a plan switch; returns the notice label. */
368
+ cycleMode(): string
369
+ /** Pre-session plan choice: shows the plan badge before the first session exists. */
370
+ pendingPlan?: boolean
363
371
  /** Select or inspect a permission preset without requiring a pre-existing session. */
364
372
  setPermission(id: string): string
365
373
  /** Export the transcript to a markdown file (/export [path]); reports via notices. */
@@ -389,6 +397,10 @@ export interface AppProps {
389
397
  loadPlugins(): readonly PluginRow[]
390
398
  /** Caller-visible background jobs (the host jobs registry, read-only). */
391
399
  loadJobs(): readonly JobRow[]
400
+ /** Probe the launcher's aligned update plan (read-only; never installs). */
401
+ probeUpdate(): Promise<LauncherUpdateStatus>
402
+ /** Run the launcher's aligned update; streams sanitized lines; resolves with the exit code. */
403
+ applyUpdate(onLine: (line: string) => void): Promise<number>
392
404
  /** Registers the app's notice channel with the runner (called once on mount). */
393
405
  onBridgeReady(bridge: { notify(text: string, tone?: NoticeTone): void }): void
394
406
  /** Ordered enabled status items (/statusline config); the runner owns persistence. */
@@ -928,6 +940,10 @@ function statusToneProps(tone: StatusTone): {
928
940
  return { color: inkColor(getPalette().brand), bold: undefined, dimColor: undefined }
929
941
  case 'success':
930
942
  return { color: inkColor(getPalette().code), bold: true, dimColor: undefined }
943
+ // The plan station's dedicated green: the status bar otherwise speaks in
944
+ // blues, but the fourth cycle station IS a distinct green mode marker.
945
+ case 'plan':
946
+ return { color: inkColor(getPalette().success), bold: true, dimColor: undefined }
931
947
  case 'warn':
932
948
  return { color: inkColor(getPalette().warn), bold: true, dimColor: undefined }
933
949
  case 'error':
@@ -969,12 +985,15 @@ function deepseekWaveHues(tier: DeepseekWaveTier): readonly [RgbTriple, RgbTripl
969
985
  : [palette.brandBright, palette.code, palette.brandMid]
970
986
  }
971
987
 
972
- function StatusLine({ facts, stats, busy, columns, items }: {
988
+ function StatusLine({ facts, stats, busy, columns, items, onRows }: {
973
989
  facts: StatusFacts
974
990
  stats: Parameters<typeof layoutStatusBar>[1]
975
991
  busy: boolean
976
992
  columns: number
977
993
  items: readonly string[]
994
+ /** Reports the footer's exact physical row count (1 or 2) so the IME
995
+ * anchor ledger below the composer stays exact. */
996
+ onRows?: (rows: 1 | 2) => void
978
997
  }): ReactElement {
979
998
  const layout = useMemo(() => layoutStatusBar(facts, stats, Math.max(8, columns - 2), {
980
999
  busy,
@@ -1000,6 +1019,13 @@ function StatusLine({ facts, stats, busy, columns, items }: {
1000
1019
  columns,
1001
1020
  items,
1002
1021
  ])
1022
+ // The IME anchor below the composer counts every row between the caret and
1023
+ // Ink's parked cursor, so the footer reports its exact row count one-way
1024
+ // (same contract as the composer's row report).
1025
+ const statusRowCount: 1 | 2 = layout.row2.left.length > 0 ? 2 : 1
1026
+ useEffect(() => {
1027
+ onRows?.(statusRowCount)
1028
+ }, [onRows, statusRowCount])
1003
1029
 
1004
1030
  const renderRow = (row: { left: readonly StatusGroup[]; right: readonly StatusSpan[]; hint: boolean }, key: string, indent = 0): ReactElement => {
1005
1031
  const leftParts: ReactElement[] = []
@@ -1638,38 +1664,52 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1638
1664
  onRetry(): void
1639
1665
  onClose(): void
1640
1666
  }): ReactElement {
1667
+ const [query, setQuery] = useState('')
1641
1668
  const [cursor, setCursor] = useState(0)
1642
1669
  const stdout = useStdout().stdout
1643
1670
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
1644
1671
  const rows = directory?.rows ?? []
1672
+ // Direct-typing filter over provider and model names (the /mode contract):
1673
+ // printable keys edit the query, so a long directory is searchable without
1674
+ // a separate search mode. With a query active, q/r/g/G stop acting as
1675
+ // commands and become query text instead.
1676
+ const filtered = useMemo(() => {
1677
+ if (query === '') return rows
1678
+ const needle = query.toLowerCase()
1679
+ return rows.filter(row => `${row.provider} ${row.providerName ?? ''} ${row.model} ${row.modelName}`.toLowerCase().includes(needle))
1680
+ }, [rows, query])
1645
1681
  const positioned = useRef(false)
1646
1682
 
1647
1683
  useEffect(() => {
1648
1684
  // Open ON the applied model (Codex resumes the previous pick): the first
1649
1685
  // non-empty directory positions the cursor once, never on later refreshes.
1650
1686
  if (positioned.current || rows.length === 0 || current === undefined) {
1651
- if (rows.length === 0) {
1687
+ if (filtered.length === 0) {
1652
1688
  if (cursor !== 0) setCursor(0)
1653
1689
  return
1654
1690
  }
1655
- if (cursor >= rows.length) setCursor(rows.length - 1)
1691
+ if (cursor >= filtered.length) setCursor(filtered.length - 1)
1656
1692
  return
1657
1693
  }
1658
1694
  const index = rows.findIndex(row => `${row.provider}/${row.model}` === current)
1659
1695
  if (index >= 0) {
1660
1696
  positioned.current = true
1661
- setCursor(index)
1662
- } else if (cursor >= rows.length) {
1663
- setCursor(Math.max(0, rows.length - 1))
1697
+ // Position within the ACTIVE filter: the full-row index means nothing
1698
+ // when the query already narrowed the list while the directory loaded
1699
+ // (a late resolve must not place the cursor outside `filtered`).
1700
+ const filteredIndex = filtered.indexOf(rows[index]!)
1701
+ setCursor(filteredIndex >= 0 ? filteredIndex : 0)
1702
+ } else if (cursor >= filtered.length) {
1703
+ setCursor(Math.max(0, filtered.length - 1))
1664
1704
  }
1665
- }, [rows, cursor, current])
1705
+ }, [rows, filtered, cursor, current])
1666
1706
 
1667
1707
  useInput((input, key) => {
1668
- if (key.escape || input === 'q') {
1708
+ if (key.escape || (input === 'q' && query === '')) {
1669
1709
  onClose()
1670
1710
  return
1671
1711
  }
1672
- if (input === 'r') {
1712
+ if (input === 'r' && query === '') {
1673
1713
  onRetry()
1674
1714
  return
1675
1715
  }
@@ -1682,13 +1722,19 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1682
1722
  onClose()
1683
1723
  return
1684
1724
  }
1685
- if (rows.length === 0) return
1725
+ const next = editQuery(query, input, key)
1726
+ if (next !== undefined) {
1727
+ setQuery(next)
1728
+ setCursor(0)
1729
+ return
1730
+ }
1731
+ if (filtered.length === 0) return
1686
1732
  if (key.upArrow) {
1687
- setCursor(cursor > 0 ? cursor - 1 : rows.length - 1)
1733
+ setCursor(cursor > 0 ? cursor - 1 : filtered.length - 1)
1688
1734
  return
1689
1735
  }
1690
1736
  if (key.downArrow) {
1691
- setCursor(cursor < rows.length - 1 ? cursor + 1 : 0)
1737
+ setCursor(cursor < filtered.length - 1 ? cursor + 1 : 0)
1692
1738
  return
1693
1739
  }
1694
1740
  if (key.pageUp) {
@@ -1696,32 +1742,27 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1696
1742
  return
1697
1743
  }
1698
1744
  if (key.pageDown) {
1699
- setCursor(current => Math.min(rows.length - 1, current + Math.max(1, viewport.bodyRows - 1)))
1700
- return
1701
- }
1702
- if (input === 'g') {
1703
- setCursor(0)
1745
+ setCursor(current => Math.min(filtered.length - 1, current + Math.max(1, viewport.bodyRows - 1)))
1704
1746
  return
1705
1747
  }
1706
- if (input === 'G') {
1707
- setCursor(rows.length - 1)
1708
- return
1709
- }
1710
- if (key.return && rows[cursor] !== undefined) {
1711
- onSelect(rows[cursor])
1748
+ if (key.return && filtered[cursor] !== undefined) {
1749
+ onSelect(filtered[cursor])
1712
1750
  }
1713
1751
  })
1714
1752
 
1715
1753
  if (viewport.maxHeight === 0 || viewport.compact) {
1716
1754
  const providers = onProviders === undefined ? '' : ' · tab providers'
1717
- const state = rows.length === 0
1755
+ const state = filtered.length === 0
1718
1756
  ? directory === undefined && error === undefined
1719
1757
  ? 'loading…'
1720
1758
  : error !== undefined
1721
1759
  ? 'error'
1722
- : 'no models'
1723
- : `❯ ${rows[cursor]?.modelName ?? rows[cursor]?.model ?? ''}`
1724
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/model · ${state}${providers} · r retry · esc/q close`, viewport.contentColumns))
1760
+ : query === '' ? 'no models' : `no match for '${singleLineText(query)}'`
1761
+ : `❯ ${filtered[cursor]?.modelName ?? filtered[cursor]?.model ?? ''}`
1762
+ const tail = query === ''
1763
+ ? 'type to filter · r retry · esc/q close'
1764
+ : 'backspace edits · esc close'
1765
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/model · ${state}${providers} · ${tail}`, viewport.contentColumns))
1725
1766
  }
1726
1767
 
1727
1768
  const stateRows: ReactElement[] = directory === undefined && error === undefined
@@ -1742,23 +1783,27 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1742
1783
  )]),
1743
1784
  ...(rows.length === 0
1744
1785
  ? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, ' no models available')]
1745
- : []),
1786
+ : filtered.length === 0
1787
+ ? [createElement(Text, { key: 'no-match', dimColor: true, wrap: 'truncate-end' }, truncateColumns(` no models match '${singleLineText(query)}'`, viewport.contentColumns))]
1788
+ : []),
1746
1789
  ]
1747
1790
  // Measurement and rendering share the same physical-row budget: state
1748
1791
  // messages consume body rows before selectable entries, as in Codex's
1749
1792
  // list-selection views.
1750
1793
  const visibleStateRows = stateRows.slice(0, viewport.bodyRows)
1751
1794
  const rowBudget = Math.max(0, viewport.bodyRows - visibleStateRows.length)
1752
- const first = selectionWindow(cursor, rows.length, rowBudget)
1753
- const visible = rowBudget === 0 ? [] : rows.slice(first, first + rowBudget)
1795
+ const first = selectionWindow(cursor, filtered.length, rowBudget)
1796
+ const visible = rowBudget === 0 ? [] : filtered.slice(first, first + rowBudget)
1754
1797
  return createElement(
1755
1798
  Box,
1756
1799
  { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
1757
- createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model — select model${rows.length === 0 ? '' : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)),
1800
+ createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(query === ''
1801
+ ? `/model — select model${rows.length === 0 ? '' : ` · ${cursor + 1}/${rows.length}`}`
1802
+ : `/model — select model · ${filtered.length} of ${rows.length} match '${singleLineText(query)}'`, viewport.contentColumns)),
1758
1803
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1759
1804
  ...visibleStateRows,
1760
1805
  ...visible.map((row) => {
1761
- const index = rows.indexOf(row)
1806
+ const index = filtered.indexOf(row)
1762
1807
  const capability = row.inputModalities?.includes('image') === true ? ' · image' : ''
1763
1808
  const label = displayText(`${row.providerName} · ${row.modelName}${capability}`)
1764
1809
  return createElement(
@@ -1772,7 +1817,9 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
1772
1817
  )
1773
1818
  }),
1774
1819
  createElement(PanelGap, { visible: viewport.gapRows > 0 }),
1775
- createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(`↑↓ move · pgup/pgdn page · enter select${onProviders === undefined ? '' : ' · tab providers'} · r retry · esc/q close`, viewport.contentColumns))),
1820
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(query === ''
1821
+ ? `type to filter · ↑↓ move · pgup/pgdn page · enter select${onProviders === undefined ? '' : ' · tab providers'} · r retry · esc/q close`
1822
+ : `↑↓ move · pgup/pgdn page · enter select · backspace edits · esc close`, viewport.contentColumns))),
1776
1823
  )
1777
1824
  }
1778
1825
 
@@ -3187,9 +3234,12 @@ interface DraftFile extends FilePathInspection {
3187
3234
  * While a modal (approval / question / model panel) owns the keys, the
3188
3235
  * box passes every key through untouched.
3189
3236
  */
3190
- function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openEffort, openHelp, openMode, openPermission, openResume, openPlugin, openJobs, openStatusline, openTheme, openHistory, openAgents, openSubagent, openTodos, openDelete, openDiff, reviewChanges, deleteConfirm, confirmDelete, cancelDelete, createSession, forkSession, cancelSessionSwitch, notify, applyEditorKeys, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, inspectImages, prepareImages, inspectFiles, prepareFiles, cyclePermission, exportTranscript, renameTitle, copyLastResponse, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed, animations, applyAnimations, waveTier, waveStyle, maxRows, onEditorRows, onMenuRows, sessionKey }: {
3237
+ function Input({ active, frozen, frozenHint, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openEffort, openHelp, openMode, openPermission, openResume, openPlugin, openUpdate, openSchedule, openJobs, openStatusline, openTheme, openHistory, openAgents, openSubagent, openTodos, openDelete, openDiff, reviewChanges, deleteConfirm, confirmDelete, cancelDelete, createSession, forkSession, cancelSessionSwitch, notify, applyEditorKeys, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, inspectImages, prepareImages, inspectFiles, prepareFiles, cycleMode, exportTranscript, renameTitle, copyLastResponse, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed, animations, applyAnimations, waveTier, waveStyle, maxRows, anchorRowsBelow, tabTitle, onEditorRows, onMenuRows, sessionKey }: {
3191
3238
  active: boolean
3192
3239
  frozen: boolean
3240
+ /** Frozen-band hint naming the surface that owns the keyboard; an empty
3241
+ * draft otherwise advertises typing that the composer cannot accept. */
3242
+ frozenHint?: string
3193
3243
  busy: boolean
3194
3244
  descriptors: readonly CommandDescriptor[]
3195
3245
  skills: readonly SkillRow[]
@@ -3206,6 +3256,10 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3206
3256
  openPermission(): void
3207
3257
  openResume(): void
3208
3258
  openPlugin(query?: string): void
3259
+ /** Open the /update panel (aligned upgrade surface). */
3260
+ openUpdate(): void
3261
+ /** Open the /schedule reminder panel (read-only catalog). */
3262
+ openSchedule(): void
3209
3263
  openJobs(): void
3210
3264
  openStatusline(): void
3211
3265
  openTheme(): void
@@ -3243,7 +3297,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3243
3297
  prepareImages(paths: readonly string[], signal?: AbortSignal): Promise<readonly ImageBlock[]>
3244
3298
  inspectFiles(paths: readonly string[]): Promise<readonly FilePathInspection[]>
3245
3299
  prepareFiles(paths: readonly string[], signal?: AbortSignal): Promise<readonly FileBlock[]>
3246
- cyclePermission(): string
3300
+ cycleMode(): string
3247
3301
  exportTranscript(argument: string): Promise<void>
3248
3302
  renameTitle(argument: string): string
3249
3303
  copyLastResponse(): Promise<string>
@@ -3275,14 +3329,25 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3275
3329
  waveStyle: DeepseekWaveStyle | null
3276
3330
  /** Maximum physical editor rows the composer may occupy (see composerMaxRows). */
3277
3331
  maxRows: number
3332
+ /** Terminal rows below the composer the editor does not own: the status
3333
+ * footer and Ink's parked cursor row. The IME anchor adds these to the
3334
+ * caret's in-band offset to reach that parked position. */
3335
+ anchorRowsBelow: number
3336
+ /** The managed terminal tab label; re-asserted on terminal focus-in so a
3337
+ * background process sharing the console cannot keep it overwritten. */
3338
+ tabTitle: string
3278
3339
  /** Reports the editor's current physical row count so the live budget stays exact. */
3279
3340
  onEditorRows(rows: number): void
3280
3341
  /** Reports the open completion menu's physical row count (0 when closed)
3281
3342
  * for the same reason: the dynamic budget must reserve it, not overflow. */
3282
3343
  onMenuRows(rows: number): void
3283
3344
  }): ReactElement {
3284
- const columns = useStdout().stdout?.columns ?? 80
3285
- const inputTerminalRows = useStdout().stdout?.rows ?? 30
3345
+ const { stdout: inputStdout } = useStdout()
3346
+ const columns = inputStdout?.columns ?? 80
3347
+ const inputTerminalRows = inputStdout?.rows ?? 30
3348
+ // The managed tab label, kept current for the focus-in re-assert below.
3349
+ const tabTitleRef = useRef(tabTitle)
3350
+ tabTitleRef.current = tabTitle
3286
3351
  const editorColumns = Math.max(1, columns - 6)
3287
3352
  const stdin = useStdin().stdin
3288
3353
  const focusReporting = isVsCodeTerminalEnv()
@@ -3384,6 +3449,14 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3384
3449
  const input = focusReporting
3385
3450
  ? stripTerminalFocusEvents(normalized, focused => {
3386
3451
  terminalFocusedRef.current = focused
3452
+ // Focus-in re-asserts the managed tab label on both channels: a
3453
+ // background process sharing this console (a test-runner worker,
3454
+ // for example) may have overwritten the console title while the
3455
+ // terminal was unfocused.
3456
+ if (focused && inputStdout !== undefined) {
3457
+ inputStdout.write(terminalTitleSequence(tabTitleRef.current))
3458
+ process.title = sanitizeTerminalTitle(tabTitleRef.current)
3459
+ }
3387
3460
  })
3388
3461
  : normalized
3389
3462
  rawEditorTokens.current = tokenizeRawEditorChunk(input)
@@ -3732,20 +3805,16 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3732
3805
  notify('image submission cancelled', 'warning')
3733
3806
  }
3734
3807
 
3735
- /** Move through visual rows first, then cross history at the true edge. */
3808
+ /** Cross history while an unchanged recalled draft rests its caret on
3809
+ * either text edge; between the edges (or inside ordinary drafts) the
3810
+ * arrows move through visual rows first. */
3736
3811
  const navigateVertical = (direction: -1 | 1): void => {
3737
3812
  const currentValue = valueRef.current
3738
3813
  const currentCursor = cursorRef.current
3739
- const model = editorModel(currentValue, editorColumns)
3740
- const preferred = preferredColumnRef.current ?? caretSite(model, currentCursor).column
3741
- const next = moveCursorVertically(model, currentCursor, preferred, direction)
3742
- if (next !== currentCursor) {
3743
- cursorRef.current = next
3744
- setCursor(next)
3745
- resetCursorBlink()
3746
- preferredColumnRef.current = preferred
3747
- return
3748
- }
3814
+ // History owns the arrows while an unchanged recalled draft rests its
3815
+ // caret on either text edge (start or end). Everywhere else - edited
3816
+ // drafts, interior carets, ordinary typing - the arrows move through
3817
+ // visual rows as plain editing.
3749
3818
  if (recall.current.entries.length > 0
3750
3819
  && shouldRecallNavigate(currentValue, currentCursor, recall.current.lastRecalled, direction)) {
3751
3820
  const step = direction < 0 ? recallOlder(recall.current, currentValue) : recallNewer(recall.current)
@@ -3757,10 +3826,24 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3757
3826
  setValue(safe)
3758
3827
  setCursor(safe.length)
3759
3828
  preferredColumnRef.current = null
3760
- setDismissedMenuValue(undefined)
3829
+ // Suppress the completion menu for the recalled text: a recalled
3830
+ // command would otherwise reopen the menu, whose Up/Down navigation
3831
+ // then traps the walk before it reaches older history entries. Any
3832
+ // edit re-opens the menu; submitting resets the dismissal.
3833
+ setDismissedMenuValue(safe)
3761
3834
  }
3835
+ resetCursorBlink()
3836
+ return
3837
+ }
3838
+ const model = editorModel(currentValue, editorColumns)
3839
+ const preferred = preferredColumnRef.current ?? caretSite(model, currentCursor).column
3840
+ const next = moveCursorVertically(model, currentCursor, preferred, direction)
3841
+ if (next !== currentCursor) {
3842
+ cursorRef.current = next
3843
+ setCursor(next)
3844
+ resetCursorBlink()
3845
+ preferredColumnRef.current = preferred
3762
3846
  }
3763
- resetCursorBlink()
3764
3847
  }
3765
3848
 
3766
3849
  useStableInput((input, key) => {
@@ -3787,11 +3870,12 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3787
3870
  }
3788
3871
  return
3789
3872
  }
3790
- // Shift+Tab cycles the permission preset (Claude-Code convention).
3873
+ // Shift+Tab cycles the mode stations: permission presets, then the
3874
+ // plan station when the composition offers it (Claude-Code convention).
3791
3875
  if (key.tab && key.shift) {
3792
3876
  try {
3793
- const next = cyclePermission()
3794
- if (next !== '') notify(`permission → ${next}`)
3877
+ const label = cycleMode()
3878
+ if (label !== '') notify(label)
3795
3879
  } catch (error: unknown) {
3796
3880
  notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
3797
3881
  }
@@ -3958,13 +4042,12 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
3958
4042
  setDismissedMenuValue(undefined)
3959
4043
  if (trimmed === '') return
3960
4044
  dismissNotice()
3961
- // Global recall records non-slash submissions only (slash lines are
3962
- // commands, not prompts) Codex record_local_submission semantics;
3963
- // the submission resets any active recall browsing.
3964
- if (!text.startsWith('/')) {
3965
- recordLocal(text)
3966
- recordHistory(text)
3967
- }
4045
+ // Global recall records every submission - prompts and typed slash
4046
+ // commands share one history, so Up/Down and /history recall commands
4047
+ // exactly like prompts; the submission resets any active recall
4048
+ // browsing.
4049
+ recordLocal(text)
4050
+ recordHistory(text)
3968
4051
  recall.current = beginRecall(recallSpace, '')
3969
4052
  if (text === '/quit') {
3970
4053
  quit()
@@ -4056,6 +4139,14 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
4056
4139
  openPlugin(text.slice(7).trim())
4057
4140
  return
4058
4141
  }
4142
+ if (text === '/update') {
4143
+ openUpdate()
4144
+ return
4145
+ }
4146
+ if (text === '/schedule') {
4147
+ openSchedule()
4148
+ return
4149
+ }
4059
4150
  if (text === '/jobs' || text.startsWith('/jobs ')) {
4060
4151
  openJobs()
4061
4152
  return
@@ -4315,6 +4406,17 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
4315
4406
  useEffect(() => {
4316
4407
  onEditorRows(editorRowCount)
4317
4408
  }, [editorRowCount, onEditorRows])
4409
+ // IME anchor: park the real terminal cursor on the caret cell while the
4410
+ // composer accepts input. IME composition and candidate windows anchor to
4411
+ // that real cursor cell, which otherwise sits below the status row where
4412
+ // Ink leaves it, so Chinese input never appears at the caret. Frozen bands
4413
+ // release the anchor; the wrapper keeps Ink's relative erase ledger exact.
4414
+ const caretRowInWindow = Math.max(0, Math.min(caret.row - editorWindowStart, editorWindowRows - 1))
4415
+ useImeCursorAnchor(
4416
+ !frozen,
4417
+ imeCursorRowsUp({ editorWindowRows, caretRowInWindow, rowsBelowComposer: anchorRowsBelow }),
4418
+ 2 + caret.column,
4419
+ )
4318
4420
  // The menu's physical rows ride the same one-way report; the cleanup keeps
4319
4421
  // the reserve from outliving the menu (unmount or inactive handoff).
4320
4422
  useEffect(() => {
@@ -4350,7 +4452,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
4350
4452
  ))
4351
4453
  }
4352
4454
  const frozenLine = value === ''
4353
- ? 'type a message'
4455
+ ? frozenHint ?? 'type a message'
4354
4456
  : verboseLine(value, Math.max(1, columns - 6))
4355
4457
  return band(createElement(
4356
4458
  Text,
@@ -4833,6 +4935,8 @@ export function App(props: AppProps): ReactElement {
4833
4935
  const [resumeOpen, setResumeOpen] = useState(false)
4834
4936
  const [pluginOpen, setPluginOpen] = useState(false)
4835
4937
  const [pluginQuery, setPluginQuery] = useState('')
4938
+ const [updateOpen, setUpdateOpen] = useState(false)
4939
+ const [scheduleOpen, setScheduleOpen] = useState(false)
4836
4940
  const [jobsOpen, setJobsOpen] = useState(false)
4837
4941
  const [statuslineOpen, setStatuslineOpen] = useState(false)
4838
4942
  const [statuslineItems, setStatuslineItems] = useState<readonly StatusItemId[]>(() => parseStatuslineItems(props.statusline))
@@ -4912,7 +5016,7 @@ export function App(props: AppProps): ReactElement {
4912
5016
  // panel keypress.
4913
5017
  const inputActive = deleteConfirmId !== undefined
4914
5018
  ? !approvalPending && !questionPending
4915
- : !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !jobsOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !todosOpen && !verboseOpen && diffView === undefined && !approvalPending && !questionPending
5019
+ : !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !updateOpen && !scheduleOpen && !jobsOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !todosOpen && !verboseOpen && diffView === undefined && !approvalPending && !questionPending
4916
5020
 
4917
5021
  // Human questions outrank local inspectors. Close the lower modal instead
4918
5022
  // of leaving an approval/question visible but keyboard-locked behind it.
@@ -4927,6 +5031,8 @@ export function App(props: AppProps): ReactElement {
4927
5031
  setPermissionOpen(false)
4928
5032
  setResumeOpen(false)
4929
5033
  setPluginOpen(false)
5034
+ setUpdateOpen(false)
5035
+ setScheduleOpen(false)
4930
5036
  setStatuslineOpen(false)
4931
5037
  setThemeOpen(false)
4932
5038
  setHistoryOpen(false)
@@ -5029,6 +5135,16 @@ export function App(props: AppProps): ReactElement {
5029
5135
  const handleMenuRows = useCallback((rows: number): void => {
5030
5136
  setMenuRows(current => (current === rows ? current : rows))
5031
5137
  }, [])
5138
+ // The status footer's exact row count, reported one-way by StatusLine (the
5139
+ // second row renders only while it has content). The IME cursor anchor
5140
+ // counts every row between the composer caret and Ink's parked cursor: the
5141
+ // status footer plus Ink's own below-frame row. The gutter rows sit ABOVE
5142
+ // the composer and never enter this distance.
5143
+ const [statusBarRows, setStatusBarRows] = useState<1 | 2>(1)
5144
+ const handleStatusRows = useCallback((rows: 1 | 2): void => {
5145
+ setStatusBarRows(current => (current === rows ? current : rows))
5146
+ }, [])
5147
+ const imeRowsBelowComposer = statusBarRows + 1
5032
5148
  const composerEditorCap = composerMaxRows(terminalRows)
5033
5149
  // Bottom chrome is composer (2 borders + composerRows) + status (up to 2
5034
5150
  // rows) + todo/agents/notice (3) = 8 resting rows, plus the historical
@@ -5040,6 +5156,10 @@ export function App(props: AppProps): ReactElement {
5040
5156
  const dynamicRows = Math.max(1, terminalRows - 8 - MENU_RESERVE_ROWS - composerGutterRows - (composerRows - 1) - Math.max(0, menuRows - MENU_RESERVE_ROWS))
5041
5157
  const streamingActive = view.streaming !== '' || view.streamingReasoning !== ''
5042
5158
  const deepDivingVisible = busy && !streamingActive
5159
+ // Terminal tab label: "deepseek" until the session carries a name, then the
5160
+ // session title; cleared on unmount so the host shell regains its default.
5161
+ const tabTitle = view.title === '' ? DEFAULT_TERMINAL_TITLE : view.title
5162
+ useTerminalTitle(tabTitle)
5043
5163
  const allLiveLines = useMemo(
5044
5164
  () => view.entries.slice(settled).flatMap(
5045
5165
  // Width shrinks with the real terminal (no 10-column floor: on a
@@ -5088,9 +5208,55 @@ export function App(props: AppProps): ReactElement {
5088
5208
  : visibleLiveLines.slice(-liveAudit.allocation.live)
5089
5209
  const auditedReasoningRows = liveAudit.allocation.reasoning
5090
5210
  const auditedAnswerRows = liveAudit.allocation.answer
5091
- const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !jobsOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !todosOpen && !verboseOpen && diffView === undefined && !approvalPending && !questionPending
5211
+ const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !updateOpen && !scheduleOpen && !jobsOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !todosOpen && !verboseOpen && diffView === undefined && !approvalPending && !questionPending
5092
5212
  const inspectorVisible = verboseOpen && !approvalPending && !questionPending
5093
- const modalVisible = modelOpen || helpOpen || modeOpen || permissionOpen || resumeOpen || pluginOpen || jobsOpen || statuslineOpen || themeOpen || historyOpen || agentsOpen || subagentOpen || todosOpen || inspectorVisible || diffView !== undefined || approvalPending || questionPending
5213
+ const modalVisible = modelOpen || helpOpen || modeOpen || permissionOpen || resumeOpen || pluginOpen || updateOpen || scheduleOpen || jobsOpen || statuslineOpen || themeOpen || historyOpen || agentsOpen || subagentOpen || todosOpen || inspectorVisible || diffView !== undefined || approvalPending || questionPending
5214
+ // The surface that currently owns the keyboard, named in the frozen band:
5215
+ // an empty composer under a panel must not advertise typing it cannot
5216
+ // accept — every key actually feeds the panel (which may or may not
5217
+ // filter with it), so the honest hint names the owner and the way out.
5218
+ const keyboardOwner = approvalPending
5219
+ ? 'the approval prompt'
5220
+ : questionPending
5221
+ ? 'the question'
5222
+ : diffView !== undefined
5223
+ ? 'the diff review'
5224
+ : modelOpen
5225
+ ? '/model'
5226
+ : helpOpen
5227
+ ? '/help'
5228
+ : modeOpen
5229
+ ? '/mode'
5230
+ : permissionOpen
5231
+ ? '/permission'
5232
+ : resumeOpen
5233
+ ? '/resume'
5234
+ : pluginOpen
5235
+ ? '/plugin'
5236
+ : updateOpen
5237
+ ? '/update'
5238
+ : scheduleOpen
5239
+ ? '/schedule'
5240
+ : jobsOpen
5241
+ ? '/jobs'
5242
+ : statuslineOpen
5243
+ ? '/statusline'
5244
+ : themeOpen
5245
+ ? '/theme'
5246
+ : historyOpen
5247
+ ? '/history'
5248
+ : agentsOpen
5249
+ ? '/agents'
5250
+ : subagentOpen
5251
+ ? '/subagent'
5252
+ : todosOpen
5253
+ ? '/todos'
5254
+ : inspectorVisible
5255
+ ? 'history details'
5256
+ : undefined
5257
+ const frozenHint = keyboardOwner === undefined
5258
+ ? undefined
5259
+ : `keys go to ${keyboardOwner} · esc ${approvalPending ? 'rejects' : questionPending ? 'cancels' : 'closes'}`
5094
5260
  const closeInspector = useCallback((): void => {
5095
5261
  setVerboseOpen(false)
5096
5262
  }, [])
@@ -5492,6 +5658,17 @@ export function App(props: AppProps): ReactElement {
5492
5658
  pluginOpen && !approvalPending && !questionPending
5493
5659
  ? createElement(PluginPanel, { load: props.loadPlugins, initialQuery: pluginQuery, close: () => setPluginOpen(false) })
5494
5660
  : undefined,
5661
+ updateOpen && !approvalPending && !questionPending
5662
+ ? createElement(UpdatePanel, {
5663
+ probe: props.probeUpdate,
5664
+ apply: props.applyUpdate,
5665
+ notify: (text: string, tone?: NoticeTone) => notify(text, tone),
5666
+ close: () => setUpdateOpen(false),
5667
+ })
5668
+ : undefined,
5669
+ scheduleOpen && !approvalPending && !questionPending
5670
+ ? createElement(SchedulePanel, { rows: () => view.schedules, close: () => setScheduleOpen(false) })
5671
+ : undefined,
5495
5672
  jobsOpen && !approvalPending && !questionPending
5496
5673
  ? createElement(JobsPanel, { load: props.loadJobs, close: () => setJobsOpen(false) })
5497
5674
  : undefined,
@@ -5577,6 +5754,7 @@ export function App(props: AppProps): ReactElement {
5577
5754
  createElement(Input, {
5578
5755
  active: inputActive,
5579
5756
  frozen: modalVisible,
5757
+ frozenHint,
5580
5758
  busy,
5581
5759
  descriptors,
5582
5760
  skills,
@@ -5644,6 +5822,8 @@ export function App(props: AppProps): ReactElement {
5644
5822
  openPermission: () => setPermissionOpen(true),
5645
5823
  openResume: () => { setResumeDelete({ mode: false }); setResumeOpen(true) },
5646
5824
  openPlugin: (query = '') => { setPluginQuery(query); setPluginOpen(true) },
5825
+ openUpdate: () => setUpdateOpen(true),
5826
+ openSchedule: () => setScheduleOpen(true),
5647
5827
  openJobs: () => setJobsOpen(true),
5648
5828
  openStatusline: () => setStatuslineOpen(true),
5649
5829
  openTheme: () => setThemeOpen(true),
@@ -5698,7 +5878,7 @@ export function App(props: AppProps): ReactElement {
5698
5878
  inspectFiles: props.inspectFiles,
5699
5879
  prepareFiles: props.prepareFiles,
5700
5880
  sessionKey: props.sessionKey,
5701
- cyclePermission: props.cyclePermission,
5881
+ cycleMode: props.cycleMode,
5702
5882
  exportTranscript: props.exportTranscript,
5703
5883
  renameTitle: props.renameTitle,
5704
5884
  copyLastResponse: props.copyLastResponse,
@@ -5714,6 +5894,8 @@ export function App(props: AppProps): ReactElement {
5714
5894
  waveTier,
5715
5895
  waveStyle,
5716
5896
  maxRows: composerEditorCap,
5897
+ anchorRowsBelow: imeRowsBelowComposer,
5898
+ tabTitle,
5717
5899
  onEditorRows: handleEditorRows,
5718
5900
  onMenuRows: handleMenuRows,
5719
5901
  }),
@@ -5725,7 +5907,7 @@ export function App(props: AppProps): ReactElement {
5725
5907
  branch: props.branch,
5726
5908
  sessionId: props.sessionId,
5727
5909
  title: view.title,
5728
- plan: view.plan,
5910
+ plan: view.plan || props.pendingPlan === true,
5729
5911
  permission: view.permission !== '' ? view.permission : props.permission,
5730
5912
  sandbox: view.sandbox,
5731
5913
  goal: view.goal === undefined ? undefined : { phase: view.goal.phase, rounds: view.goal.rounds, max: view.goal.max },
@@ -5734,6 +5916,7 @@ export function App(props: AppProps): ReactElement {
5734
5916
  busy,
5735
5917
  columns: terminalColumns,
5736
5918
  items: statuslineItems,
5919
+ onRows: handleStatusRows,
5737
5920
  }),
5738
5921
  ),
5739
5922
  )