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/README.en.md +60 -9
- package/README.md +60 -9
- package/bin/deepseek.mjs +86 -1
- package/cordis.patch.yml +88 -0
- package/lib/index.mjs +1002 -94
- package/lib/session-query.mjs +149 -0
- package/lib/types/app.d.ts +9 -2
- package/lib/types/index.d.ts +27 -0
- package/lib/types/kernel-panels.d.ts +23 -0
- package/lib/types/render/editor.d.ts +4 -3
- package/lib/types/render/ime-cursor.d.ts +60 -0
- package/lib/types/render/projection.d.ts +35 -0
- package/lib/types/render/status.d.ts +1 -1
- package/lib/types/session-query.d.ts +92 -0
- package/lib/types/terminal-title.d.ts +58 -0
- package/lib/types/update-panel.d.ts +49 -0
- package/lib/types/update.d.ts +66 -0
- package/package.json +228 -89
- package/src/app.ts +252 -69
- package/src/index.ts +129 -11
- package/src/internals.ts +5 -0
- package/src/kernel-panels.ts +89 -3
- package/src/render/editor.ts +5 -4
- package/src/render/ime-cursor.ts +147 -0
- package/src/render/projection.ts +144 -3
- package/src/render/status.ts +18 -4
- package/src/session-query.ts +235 -0
- package/src/terminal-title.ts +173 -0
- package/src/update-panel.ts +246 -0
- package/src/update.ts +110 -0
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
|
-
*
|
|
1161
|
-
*
|
|
1162
|
-
*
|
|
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
|
|
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
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
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
|
-
|
|
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(`
|
|
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
|
-
|
|
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
|
package/src/kernel-panels.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/render/editor.ts
CHANGED
|
@@ -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
|
|
491
|
-
*
|
|
492
|
-
*
|
|
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
|
|
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
|
+
}
|