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/index.ts CHANGED
@@ -76,13 +76,13 @@ import { SessionSwitchQueue } from './session-switch.ts'
76
76
  import { agentPresetsFrom, normalizePresetId, resolvePreset, selectPreset } from './presets.ts'
77
77
  import {
78
78
  applyPendingPermission,
79
- cyclePermission as cyclePermissionPreset,
80
79
  effectivePermission,
81
80
  listPermissionRows,
82
81
  permissionPresetsFrom,
83
82
  selectPermission,
84
83
  } from './permissions.ts'
85
84
  import { listPluginRows } from './plugin-inventory.ts'
85
+ import { applyLauncherUpdate, probeLauncherUpdate } from './update.ts'
86
86
  import { parseAnimationsPref } from './render/animations.ts'
87
87
  import { parseThemeName, setTheme, type ThemeName } from './theme.ts'
88
88
  import {
@@ -269,6 +269,38 @@ export function submissionBelongsToSession(origin: string | undefined, activeSes
269
269
  return origin === undefined || origin === '' || origin === activeSessionId
270
270
  }
271
271
 
272
+ /** One Shift+Tab station decision for the mode cycle. */
273
+ export type ModeCycleDecision =
274
+ | { readonly kind: 'permission'; readonly preset: string }
275
+ | { readonly kind: 'plan-on' }
276
+ | { readonly kind: 'plan-off'; readonly preset: string }
277
+
278
+ /**
279
+ * Decide the next Shift+Tab station. The cycle keeps the preset table's
280
+ * own order (most restrictive first) and inserts ONE plan station between
281
+ * the most restrictive preset and the wrap target: with the shipped three
282
+ * presets the user sees workspace-write → danger-full-access → read-only
283
+ * → plan → workspace-write. Plan IS the most restrictive preset plus the
284
+ * plan prompt layer — entering it switches nothing (the cycle is already
285
+ * parked on read-only), and leaving it lands on the next preset after the
286
+ * most restrictive one. Without the /plan command the cycle is exactly the
287
+ * preset table.
288
+ */
289
+ export function planCycleDecision(input: {
290
+ readonly names: readonly string[]
291
+ readonly current: string
292
+ readonly inPlan: boolean
293
+ readonly planAvailable: boolean
294
+ }): ModeCycleDecision | undefined {
295
+ const names = input.names
296
+ if (names.length === 0) return undefined
297
+ const first = names[0]!
298
+ if (input.inPlan) return { kind: 'plan-off', preset: names[1] ?? first }
299
+ const at = names.indexOf(input.current)
300
+ if (at === 0 && input.planAvailable) return { kind: 'plan-on' }
301
+ return { kind: 'permission', preset: names[(at + 1) % names.length] ?? first }
302
+ }
303
+
272
304
  /**
273
305
  * Order-preserving gate for composer input while the startup prompt/images
274
306
  * are still preparing. Anything submitted before the startup delivery settles
@@ -511,6 +543,38 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
511
543
  let pendingModeWork: Promise<void> = Promise.resolve()
512
544
  /** Permission preset selected before the first session exists. */
513
545
  let pendingPermission: string | undefined
546
+ /**
547
+ * Plan-mode choice made before the first session exists: materialized as a
548
+ * /plan registry command delivered ahead of the first queued input when the
549
+ * session composes, so the first assembled step already plans.
550
+ */
551
+ let pendingPlan = false
552
+ /**
553
+ * Whether the pre-session effective preset composes plan mode, answered by
554
+ * the presets service composition inventory (minimal does not). Cached and
555
+ * refreshed whenever the pending mode moves; unknown reads as unavailable
556
+ * so one keypress at most lands before the answer arrives.
557
+ */
558
+ let preSessionPlanAvailable = false
559
+ let preSessionPlanKnown = false
560
+ const refreshPreSessionPlan = (): void => {
561
+ if (presets === undefined) {
562
+ preSessionPlanAvailable = false
563
+ preSessionPlanKnown = true
564
+ return
565
+ }
566
+ preSessionPlanKnown = false
567
+ void presets.compositionInventory().then(inventory => {
568
+ const id = pendingMode ?? normalizePresetId(presets.defaultId)
569
+ preSessionPlanAvailable = inventory.some(composition => composition.id === id
570
+ && composition.rows.some(row => row.moduleName === '@deepseek-ai/dsh-plan-mode' && row.enabled !== false))
571
+ preSessionPlanKnown = true
572
+ }, () => {
573
+ preSessionPlanAvailable = false
574
+ preSessionPlanKnown = true
575
+ })
576
+ }
577
+ refreshPreSessionPlan()
514
578
  /**
515
579
  * Monotonic session epoch: bumped on every successful activation, on every
516
580
  * first-session creation, and on quit. Async callbacks (mention prepares,
@@ -1063,6 +1127,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1063
1127
  abortPendingControllers()
1064
1128
  epoch += 1
1065
1129
  const queued = pendingInputs.splice(0)
1130
+ if (pendingPlan) {
1131
+ pendingPlan = false
1132
+ // A pre-session plan choice materializes as the registry command
1133
+ // delivered AHEAD of the queued lines, so the first assembled step
1134
+ // of the user's opening message already runs in plan mode.
1135
+ deliverLine('/plan', 'followup')
1136
+ }
1066
1137
  for (const item of queued) deliverLine(item.text, item.mode, item.images)
1067
1138
  } finally {
1068
1139
  creating = false
@@ -1157,24 +1228,64 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1157
1228
  }
1158
1229
 
1159
1230
  /**
1160
- * Cycle to the next permission preset (Shift+Tab). Before the first session,
1161
- * the choice remains process-local and is materialized when Harness creates
1162
- * that session; afterwards the canonical service writes durable events.
1231
+ * Shift+Tab mode cycle: permission presets in table order, then the plan
1232
+ * station when the composition offers the /plan command (preset-mounted,
1233
+ * so minimal sessions and the pre-session state cycle permissions only).
1234
+ * Plan transitions submit the upstream registry command — it stays the
1235
+ * single owner of plan state; the TUI renders the durable plan/mode event
1236
+ * it appends. Returns the notice label, or '' when nothing changed.
1163
1237
  */
1164
- const cyclePermission = (): string => {
1238
+ const cycleMode = (): string => {
1165
1239
  if (permissionPresets === undefined || permissionPresets.names.length === 0) {
1166
1240
  bridge.notify('permission presets are not mounted in this composition', 'warning')
1167
1241
  return ''
1168
1242
  }
1169
1243
  try {
1170
- const next = cyclePermissionPreset(permissionPresets, session, pendingPermission)
1171
- if (session === undefined && next !== '') {
1172
- pendingPermission = next
1244
+ // Pre-session the plan station rides the pending choice; once a
1245
+ // session exists the scoped /plan command descriptor decides, and the
1246
+ // durable plan/mode event is the live truth.
1247
+ const preSession = session === undefined
1248
+ if (preSession && !preSessionPlanKnown) refreshPreSessionPlan()
1249
+ const decision = planCycleDecision({
1250
+ names: permissionPresets.names,
1251
+ current: effectivePermission(permissionPresets, session, pendingPermission),
1252
+ inPlan: preSession ? pendingPlan : store.getView().plan === true,
1253
+ planAvailable: preSession ? preSessionPlanAvailable : commands.descriptors.some(descriptor => descriptor.name === 'plan'),
1254
+ })
1255
+ if (decision === undefined) return ''
1256
+ if (decision.kind === 'permission') {
1257
+ const next = selectPermission(permissionPresets, session, decision.preset)
1258
+ if (preSession) {
1259
+ pendingPermission = next
1260
+ renderCurrent()
1261
+ }
1262
+ return `permission → ${next}`
1263
+ }
1264
+ if (decision.kind === 'plan-on') {
1265
+ // Plan IS the most restrictive preset plus the plan prompt layer:
1266
+ // the cycle arrives here from that preset, so permission needs no
1267
+ // switch — only the plan mode itself toggles.
1268
+ if (preSession) {
1269
+ pendingPlan = true
1270
+ renderCurrent()
1271
+ return 'plan → on (applies to the first session)'
1272
+ }
1273
+ send('/plan', 'followup')
1274
+ return 'plan → on'
1275
+ }
1276
+ // Leaving plan lands on the station after the most restrictive
1277
+ // preset (workspace-write with the shipped table).
1278
+ if (preSession) {
1279
+ pendingPlan = false
1280
+ pendingPermission = decision.preset
1173
1281
  renderCurrent()
1282
+ return `plan → off · permission → ${decision.preset}`
1174
1283
  }
1175
- return next
1284
+ send('/plan off', 'followup')
1285
+ selectPermission(permissionPresets, session, decision.preset)
1286
+ return `plan → off · permission → ${decision.preset}`
1176
1287
  } catch (error: unknown) {
1177
- bridge.notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
1288
+ bridge.notify(`mode change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
1178
1289
  return ''
1179
1290
  }
1180
1291
  }
@@ -1478,6 +1589,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1478
1589
  subagents.reset()
1479
1590
  pendingMode = undefined
1480
1591
  pendingPermission = undefined
1592
+ pendingPlan = false
1481
1593
  } catch (error: unknown) {
1482
1594
  active = previous
1483
1595
  agent = previous?.agent
@@ -1703,6 +1815,8 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1703
1815
  resumed: active?.resumed ?? false,
1704
1816
  mode: active?.mode ?? pendingMode ?? normalizePresetId(presets.defaultId),
1705
1817
  permission,
1818
+ /** Pre-session plan choice for the status badge until a session composes. */
1819
+ pendingPlan: session === undefined && pendingPlan,
1706
1820
  dispatch,
1707
1821
  steer,
1708
1822
  interrupt,
@@ -1729,7 +1843,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1729
1843
  prepareImages: (paths, signal) => saveImagePaths(paths, ctx.get('attachments'), signal),
1730
1844
  inspectFiles: paths => inspectFilePaths(paths, ctx.get('attachments'), session?.header.cwd ?? cwd),
1731
1845
  prepareFiles: (paths, signal) => saveFilePaths(paths, ctx.get('attachments'), signal),
1732
- cyclePermission,
1846
+ cycleMode,
1733
1847
  setPermission: setPermissionAction,
1734
1848
  selectModel,
1735
1849
  subagentModel: subagentModelLabel(),
@@ -1759,6 +1873,10 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1759
1873
  switchSession,
1760
1874
  cancelSessionSwitch,
1761
1875
  loadPlugins: () => listPluginRows(ctx),
1876
+ // The launcher owns every update decision; the TUI only drives its
1877
+ // read-only probe and streamed apply as child processes.
1878
+ probeUpdate: () => probeLauncherUpdate(),
1879
+ applyUpdate: onLine => applyLauncherUpdate(onLine),
1762
1880
  loadJobs: () => listJobs(ctx, active?.agent),
1763
1881
  statusline: statuslineItems,
1764
1882
  saveStatusline,
package/src/internals.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  shouldEnableKeyboardEnhancement,
20
20
  } from './keyboard.ts'
21
21
  import { createSplitStdin } from './input-split.ts'
22
+ import { ensureVsCodeTabTitleSetting } from './terminal-title.ts'
22
23
 
23
24
  /** A mounted terminal app instance; the runner owns unmount ordering. */
24
25
  export interface TuiMount {
@@ -56,6 +57,10 @@ export const internals: {
56
57
  + BRACKETED_PASTE_ENABLE
57
58
  + (focusReporting ? TERMINAL_FOCUS_REPORT_ENABLE : ''),
58
59
  )
60
+ // Cosmetic best effort: inside a VS Code integrated terminal the tab
61
+ // shows the process name ("node") unless the user settings map it to
62
+ // the sequence title; align them once. Total function, never throws.
63
+ ensureVsCodeTabTitleSetting()
59
64
  // App owns Ctrl+C's deliberate three-state contract (interrupt, clear
60
65
  // draft, quit). Ink's default `exitOnCtrlC: true` would intercept the
61
66
  // normalized control byte first, unmount only its renderer, and leave the
@@ -4,6 +4,7 @@ import { createElement, useEffect, useMemo, useRef, useState, type ReactElement
4
4
  import { Box, Text, useInput, useStdout } from 'ink'
5
5
  import type { ModelDirectory, ModelRow } from './models.ts'
6
6
  import type { SubagentRow } from './subagents.ts'
7
+ import type { ScheduleRow } from './render/projection.ts'
7
8
  import type { PermissionRow } from './permissions.ts'
8
9
  import type { PresetRow } from './presets.ts'
9
10
  import type { PluginRow } from './plugin-inventory.ts'
@@ -123,7 +124,9 @@ export function ModePanel({ current, load, select, close }: {
123
124
  const visible = useMemo(() => rows.filter(row => `${row.id} ${row.name ?? ''} ${row.description ?? ''}`.toLowerCase().includes(query.toLowerCase())), [rows, query])
124
125
  useEffect(() => setCursor(value => Math.min(value, Math.max(0, visible.length - 1))), [visible.length])
125
126
  useInput((input, key) => {
126
- if (key.escape || input === 'q') return close()
127
+ if (key.escape) return close()
128
+ // q closes only while the query is empty; mid-filter it is query text.
129
+ if (input === 'q' && query === '') return close()
127
130
  if (input === 'r' && query === '') return refresh()
128
131
  if (key.upArrow) return setCursor(value => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length)
129
132
  if (key.downArrow) return setCursor(value => visible.length === 0 ? 0 : (value + 1) % visible.length)
@@ -162,7 +165,9 @@ export function PermissionPanel({ current, load, select, close }: {
162
165
  const visible = useMemo(() => rows.filter(row => `${row.id} ${row.description ?? ''}`.toLowerCase().includes(query.toLowerCase())), [rows, query])
163
166
  useEffect(() => setCursor(value => Math.min(value, Math.max(0, visible.length - 1))), [visible.length])
164
167
  useInput((input, key) => {
165
- if (key.escape || input === 'q') return close()
168
+ if (key.escape) return close()
169
+ // q closes only while the query is empty; mid-filter it is query text.
170
+ if (input === 'q' && query === '') return close()
166
171
  if (input === 'r' && query === '') return refresh()
167
172
  if (key.upArrow) return setCursor(value => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length)
168
173
  if (key.downArrow) return setCursor(value => visible.length === 0 ? 0 : (value + 1) % visible.length)
@@ -185,7 +190,9 @@ export function PluginPanel({ load, close, initialQuery = '' }: { load(): readon
185
190
  const rows = useMemo(() => load().filter(row => `${row.entryId} ${row.moduleName} ${row.phase ?? ''}`.toLowerCase().includes(query.toLowerCase())), [epoch, query])
186
191
  useEffect(() => setCursor(value => Math.min(value, Math.max(0, rows.length - 1))), [rows.length])
187
192
  useInput((input, key) => {
188
- if (key.escape || input === 'q') return close()
193
+ if (key.escape) return close()
194
+ // q closes only while the query is empty; mid-filter it is query text.
195
+ if (input === 'q' && query === '') return close()
189
196
  if (input === 'r' && query === '') return setEpoch(value => value + 1)
190
197
  if (key.upArrow) return setCursor(value => rows.length === 0 ? 0 : (value + rows.length - 1) % rows.length)
191
198
  if (key.downArrow) return setCursor(value => rows.length === 0 ? 0 : (value + 1) % rows.length)
@@ -928,3 +935,82 @@ export function SubagentPanel({ current, load, pick, inherit, close }: {
928
935
  footer: '↑↓ choose · enter apply · r refresh · esc close',
929
936
  })
930
937
  }
938
+
939
+ /**
940
+ * The /schedule panel: the read-only catalog of active reminders folded from
941
+ * durable schedule/change events (the web ui-schedule contract: overdue
942
+ * first, then ascending target; the model creates and cancels through its
943
+ * schedule_* tools, the panel only shows state). A local second-hand keeps
944
+ * the relative labels live while the panel is open.
945
+ */
946
+ export interface ScheduleDisplayRow {
947
+ readonly key: string
948
+ readonly text: string
949
+ readonly tone?: 'error'
950
+ }
951
+
952
+ /** Human frequency label: one-shot kinds read as Once, every rows carry the interval. */
953
+ export function scheduleFrequency(row: ScheduleRow): string {
954
+ if (row.kind !== 'every') return 'Once'
955
+ const seconds = row.everySeconds ?? 0
956
+ if (seconds >= 3600 && seconds % 3600 === 0) return `Every ${seconds / 3600}h`
957
+ if (seconds >= 60 && seconds % 60 === 0) return `Every ${seconds / 60}m`
958
+ return `Every ${seconds}s`
959
+ }
960
+
961
+ /** Relative label for the next target: in N unit, or N unit overdue. */
962
+ export function scheduleRelative(targetAt: number, now: number): string {
963
+ const delta = Math.max(0, Math.abs(targetAt - now))
964
+ const minutes = Math.floor(delta / 60_000)
965
+ const unit = minutes === 0
966
+ ? '<1m'
967
+ : minutes >= 60
968
+ ? `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}`
969
+ : `${minutes}m`
970
+ return targetAt <= now ? `${unit} overdue` : `in ${unit}`
971
+ }
972
+
973
+ /** Ordered display rows: overdue first (error tone), then ascending target. */
974
+ export function scheduleDisplayRows(rows: readonly ScheduleRow[], now: number): readonly ScheduleDisplayRow[] {
975
+ return [...rows]
976
+ .sort((left, right) => (Number(left.targetAt > now) - Number(right.targetAt > now)) || (left.targetAt - right.targetAt))
977
+ .map(row => ({
978
+ key: row.id,
979
+ text: `${row.prompt} · ${scheduleFrequency(row)} · ${new Date(row.targetAt).toLocaleString()} (${scheduleRelative(row.targetAt, now)})`,
980
+ tone: row.targetAt <= now ? 'error' as const : undefined,
981
+ }))
982
+ }
983
+
984
+ export function SchedulePanel({ rows, close }: { rows(): readonly ScheduleRow[]; close(): void }): ReactElement {
985
+ const [, setTick] = useState(0)
986
+ useEffect(() => {
987
+ const id = setInterval(() => setTick(value => value + 1), 1_000)
988
+ return () => clearInterval(id)
989
+ }, [])
990
+ const display = scheduleDisplayRows(rows(), Date.now())
991
+ const stdout = useStdout().stdout
992
+ const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
993
+ useInput((input, key) => {
994
+ if (key.escape || input === 'q') return close()
995
+ })
996
+ if (viewport.maxHeight === 0 || viewport.compact) {
997
+ const summary = display.length === 0 ? 'no active reminders' : singleLineText(display[0]!.text)
998
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`/schedule · ${summary}`, viewport.contentColumns))
999
+ }
1000
+ const budget = Math.max(1, viewport.bodyRows)
1001
+ const visible = display.slice(0, budget)
1002
+ const hidden = display.length - visible.length
1003
+ return createElement(
1004
+ Box,
1005
+ { width: viewport.outerColumns, borderStyle: 'round', borderColor: inkColor(getPalette().dim), flexDirection: 'column', paddingX: 1 },
1006
+ createElement(Text, { color: inkColor(getPalette().brandBright), wrap: 'truncate-end' }, truncateColumns(`/schedule · ${display.length} active reminder${display.length === 1 ? '' : 's'}`, viewport.contentColumns)),
1007
+ ...(display.length === 0
1008
+ ? [createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(' no active reminders — the model creates them with schedule_create', viewport.contentColumns))]
1009
+ : visible.map(row => createElement(Text, {
1010
+ key: row.key,
1011
+ color: row.tone === 'error' ? inkColor(getPalette().error) : undefined,
1012
+ wrap: 'truncate-end',
1013
+ }, truncateColumns(` ${singleLineText(row.text)}`, viewport.contentColumns)))),
1014
+ createElement(Text, { dimColor: true, wrap: 'truncate-end' }, truncateColumns(`esc/q close${hidden > 0 ? ` · +${hidden} more` : ''} · the model schedules via schedule_create`, viewport.contentColumns)),
1015
+ )
1016
+ }
@@ -487,12 +487,13 @@ export function composerMaxRows(terminalRows: number): number {
487
487
  }
488
488
 
489
489
  /**
490
- * History navigation starts with Up on an empty draft, or after visual
491
- * movement has reached the directional text edge of an unchanged recalled
492
- * entry. Every other position remains under textarea movement.
490
+ * History navigation starts with Up on an empty draft, or continues from an
491
+ * unchanged recalled entry whenever the caret sits on either text edge
492
+ * (start or end) - moving the caret into the interior returns the keys to
493
+ * ordinary editing until an edge is reached again.
493
494
  */
494
495
  export function shouldRecallNavigate(value: string, cursor: number, lastRecalled: string | null, direction: -1 | 1): boolean {
495
496
  if (value === '') return direction < 0
496
497
  if (lastRecalled !== value) return false
497
- return direction < 0 ? cursor === 0 : cursor === value.length
498
+ return cursor === 0 || cursor === value.length
498
499
  }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * IME cursor anchoring for the composer.
3
+ *
4
+ * Ink keeps the real terminal cursor hidden and parks it just below the
5
+ * dynamic tree (after the status row). IME composition text and candidate
6
+ * windows anchor to that real cursor cell - VS Code's integrated terminal
7
+ * positions its hidden IME textarea there, and Windows consoles behave the
8
+ * same - so CJK input appeared at the bottom of the screen instead of at the
9
+ * caret. The anchor moves the real cursor onto the caret cell without
10
+ * touching Ink's relative erase ledger:
11
+ *
12
+ * - the displacement is owned: before any foreign write or re-anchor, the
13
+ * wrapper cancels it (cursor down, column 1), so every writer - Ink's
14
+ * log-update rewrites, the resize replay, protocol pushes - keeps seeing
15
+ * the cursor exactly where it was left;
16
+ * - log-update frame chunks (which start with the erase-line sequence) get
17
+ * the anchor re-appended inside the same write, so a repaint can never
18
+ * leave the cursor behind - in particular the caret blink keeps the anchor
19
+ * stable while an IME composition is open, because the terminal parses the
20
+ * rewrite and the re-anchor as one atomic update.
21
+ *
22
+ * @module @deepseek-ai/dsh-code/render/ime-cursor
23
+ */
24
+
25
+ import { useEffect, useRef } from 'react'
26
+ import { useStdout } from 'ink'
27
+
28
+ /** Cancel `rows` of owned upward displacement and return to column 1. */
29
+ export function imeCursorRestore(rows: number): string {
30
+ return rows > 0 ? `\x1b[${rows}B\r` : ''
31
+ }
32
+
33
+ /** Move onto the caret cell: `rows` up from Ink's parked row, 0-based column. */
34
+ export function imeCursorMove(rows: number, column: number): string {
35
+ return rows > 0 ? `\x1b[${rows}A\x1b[${column + 1}G` : ''
36
+ }
37
+
38
+ /**
39
+ * Rows between the caret cell and Ink's parked cursor row: the band's bottom
40
+ * blank row, the editor window rows below the caret, the status footer, and
41
+ * Ink's own below-frame row. Rows above the composer (gutter, live content)
42
+ * never enter this distance.
43
+ */
44
+ export function imeCursorRowsUp(input: {
45
+ editorWindowRows: number
46
+ caretRowInWindow: number
47
+ rowsBelowComposer: number
48
+ }): number {
49
+ return Math.max(1, input.editorWindowRows - input.caretRowInWindow + input.rowsBelowComposer)
50
+ }
51
+
52
+ /** log-update chunks start with the erase-line sequence; a chunk without it
53
+ * leaves no frame behind, so the next commit re-applies the anchor. */
54
+ const FRAME_CHUNK_MARKER = '\x1b[2K'
55
+
56
+ /** The installed anchor handle. */
57
+ export interface ImeCursorAnchor {
58
+ /** Anchor on the caret cell: `rows` above Ink's parked row at the 0-based
59
+ * `column`; `rows <= 0` releases the anchor. */
60
+ anchor(rows: number, column: number): void
61
+ /** Cancel the displacement, restore the original write path, and detach. */
62
+ release(): void
63
+ }
64
+
65
+ const ANCHOR_STATE = Symbol.for('dsh-code.ime-cursor-anchor')
66
+
67
+ /**
68
+ * Take over `stream.write` so the anchor displacement stays invisible to every
69
+ * other writer. Idempotent per stream: a second install returns the live
70
+ * handle. Returns `undefined` on non-TTY streams where anchoring is meaningless.
71
+ */
72
+ export function installImeCursorAnchor(stream: NodeJS.WriteStream): ImeCursorAnchor | undefined {
73
+ if (stream?.isTTY !== true) return undefined
74
+ const target = stream as NodeJS.WriteStream & { [ANCHOR_STATE]?: ImeCursorAnchor | undefined }
75
+ const installed = target[ANCHOR_STATE]
76
+ if (installed !== undefined) return installed
77
+ const originalWrite = target.write.bind(target) as (...args: unknown[]) => unknown
78
+ let rows = 0
79
+ let column = -1
80
+ let detached = false
81
+ const anchor: ImeCursorAnchor = {
82
+ anchor(nextRows, nextColumn) {
83
+ if (detached) return
84
+ if (nextRows <= 0) {
85
+ if (rows !== 0) originalWrite(imeCursorRestore(rows))
86
+ rows = 0
87
+ column = -1
88
+ return
89
+ }
90
+ if (rows === nextRows && column === nextColumn) return
91
+ originalWrite(imeCursorRestore(rows) + imeCursorMove(nextRows, nextColumn))
92
+ rows = nextRows
93
+ column = nextColumn
94
+ },
95
+ release() {
96
+ if (detached) return
97
+ detached = true
98
+ if (rows !== 0) originalWrite(imeCursorRestore(rows))
99
+ rows = 0
100
+ column = -1
101
+ target.write = originalWrite as typeof target.write
102
+ delete target[ANCHOR_STATE]
103
+ },
104
+ }
105
+ target.write = ((chunk: unknown, ...rest: unknown[]) => {
106
+ const ownedRows = rows
107
+ const ownedColumn = column
108
+ if (ownedRows === 0 || typeof chunk !== 'string') {
109
+ if (ownedRows !== 0) {
110
+ originalWrite(imeCursorRestore(ownedRows))
111
+ rows = 0
112
+ column = -1
113
+ }
114
+ return originalWrite(chunk, ...rest)
115
+ }
116
+ const reanchor = chunk.startsWith(FRAME_CHUNK_MARKER)
117
+ rows = reanchor ? ownedRows : 0
118
+ column = reanchor ? ownedColumn : -1
119
+ return originalWrite(imeCursorRestore(ownedRows) + chunk + (reanchor ? imeCursorMove(ownedRows, ownedColumn) : ''), ...rest)
120
+ }) as typeof target.write
121
+ target[ANCHOR_STATE] = anchor
122
+ return anchor
123
+ }
124
+
125
+ /**
126
+ * Keep the real terminal cursor on the composer's caret cell while `active`
127
+ * (the editable composer). The second effect runs after every commit without
128
+ * a dep list: frame rewrites restore the anchor themselves, but any other
129
+ * write (protocol push, resize replay) leaves the cursor at Ink's parked
130
+ * position, and the next commit re-anchors it.
131
+ */
132
+ export function useImeCursorAnchor(active: boolean, rows: number, column: number): void {
133
+ const { stdout } = useStdout()
134
+ const anchorRef = useRef<ImeCursorAnchor | undefined>(undefined)
135
+ useEffect(() => {
136
+ if (stdout === undefined) return undefined
137
+ const installed = installImeCursorAnchor(stdout)
138
+ anchorRef.current = installed
139
+ return () => {
140
+ installed?.release()
141
+ if (anchorRef.current === installed) anchorRef.current = undefined
142
+ }
143
+ }, [stdout])
144
+ useEffect(() => {
145
+ anchorRef.current?.anchor(active && rows > 0 ? rows : 0, column)
146
+ })
147
+ }