dsh-code 1.0.5 → 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 +338 -286
- package/README.md +68 -16
- package/bin/deepseek.mjs +204 -4
- package/cordis.patch.yml +105 -7
- package/lib/index.mjs +2148 -439
- package/lib/session-query.mjs +149 -0
- package/lib/types/app.d.ts +34 -8
- package/lib/types/attachments.d.ts +36 -4
- package/lib/types/index.d.ts +38 -2
- package/lib/types/kernel-panels.d.ts +23 -0
- package/lib/types/provider-settings.d.ts +6 -11
- package/lib/types/render/animations.d.ts +74 -7
- package/lib/types/render/editor.d.ts +4 -3
- package/lib/types/render/export.d.ts +0 -6
- package/lib/types/render/fuzzy.d.ts +21 -0
- package/lib/types/render/ime-cursor.d.ts +60 -0
- package/lib/types/render/projection.d.ts +80 -4
- package/lib/types/render/status.d.ts +1 -1
- package/lib/types/session-directory.d.ts +48 -13
- package/lib/types/session-query.d.ts +92 -0
- package/lib/types/store.d.ts +3 -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 +307 -162
- package/src/app.ts +730 -266
- package/src/attachments.ts +110 -11
- package/src/commands.ts +35 -5
- package/src/index.ts +1986 -1779
- package/src/internals.ts +66 -40
- package/src/kernel-panels.ts +89 -3
- package/src/provider-settings.ts +12 -12
- package/src/render/animations.ts +606 -403
- package/src/render/editor.ts +5 -4
- package/src/render/export.ts +13 -3
- package/src/render/fuzzy.ts +83 -0
- package/src/render/ime-cursor.ts +147 -0
- package/src/render/projection.ts +1974 -1621
- package/src/render/status.ts +18 -4
- package/src/session-directory.ts +94 -16
- package/src/session-query.ts +235 -0
- package/src/skills.ts +23 -9
- package/src/store.ts +39 -1
- package/src/subagents.ts +26 -3
- package/src/terminal-title.ts +173 -0
- package/src/update-panel.ts +246 -0
- package/src/update.ts +110 -0
package/src/app.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
} from 'react'
|
|
21
21
|
import { Box, Static, Text, useInput, useStdin, useStdout, type Key } from 'ink'
|
|
22
22
|
import type { CommandDescriptor } from '@deepseek-ai/dsh-commands'
|
|
23
|
-
import type { ImageBlock } from '@deepseek-ai/dsh-llm'
|
|
23
|
+
import type { ContentBlock, FileBlock, ImageBlock } from '@deepseek-ai/dsh-llm'
|
|
24
24
|
import type { TodoItem } from '@deepseek-ai/dsh-tool-todo'
|
|
25
25
|
import type { AskUserQuestionAnswerItem, AskUserQuestionItem } from '@deepseek-ai/dsh-user-questions'
|
|
26
26
|
import type { AuthorizationInteraction, AuthorizationStatus } from '@deepseek-ai/dsh-authorization'
|
|
@@ -34,14 +34,19 @@ 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,
|
|
44
48
|
BUSY_CHASE_TICK_MS,
|
|
49
|
+
CARET_BLINK_TICK_MS,
|
|
45
50
|
caretVisible,
|
|
46
51
|
DEEP_DIVING_SHIMMER_TICK_MS,
|
|
47
52
|
DEEPSEEK_WAVE_TICK_MS,
|
|
@@ -56,11 +61,13 @@ import {
|
|
|
56
61
|
deepDivingSparkColor,
|
|
57
62
|
effortAboveHigh,
|
|
58
63
|
isOfficialDeepSeekLabel,
|
|
64
|
+
parseAnimationsArgument,
|
|
59
65
|
type DeepseekWaveStyle,
|
|
60
66
|
type DeepseekWaveTier,
|
|
61
67
|
} from './render/animations.ts'
|
|
62
68
|
import type { ApprovalSnapshot, ApprovalStore } from './approval.ts'
|
|
63
|
-
import { submissionPayload, type CommandsView } from './commands.ts'
|
|
69
|
+
import { isSlashLine, submissionPayload, type CommandsView } from './commands.ts'
|
|
70
|
+
import { rankByName } from './render/fuzzy.ts'
|
|
64
71
|
import type { ModelDirectory, ModelRow } from './models.ts'
|
|
65
72
|
import {
|
|
66
73
|
isDeclaredReasoningEfforts,
|
|
@@ -77,7 +84,7 @@ import type { QuestionSnapshot, QuestionStore } from './questions.ts'
|
|
|
77
84
|
import type { SkillsView, SkillRow } from './skills.ts'
|
|
78
85
|
import { isPathLikeMentionQuery, type MentionCandidate } from './mentions.ts'
|
|
79
86
|
import type { SubagentFeedView, SubagentRow } from './subagents.ts'
|
|
80
|
-
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'
|
|
81
88
|
import type { PresetRow } from './presets.ts'
|
|
82
89
|
import type { PermissionRow } from './permissions.ts'
|
|
83
90
|
import type { PluginRow } from './plugin-inventory.ts'
|
|
@@ -100,7 +107,8 @@ import {
|
|
|
100
107
|
import { ProviderAuthorizationLogoutPanel, ProviderAuthorizationPanel } from './authorization-panel.ts'
|
|
101
108
|
import {
|
|
102
109
|
looksLikeImagePath,
|
|
103
|
-
|
|
110
|
+
parsePastedAttachmentPaths,
|
|
111
|
+
type FilePathInspection,
|
|
104
112
|
type ImagePathInspection,
|
|
105
113
|
} from './attachments.ts'
|
|
106
114
|
|
|
@@ -196,6 +204,7 @@ import {
|
|
|
196
204
|
editorRowParts,
|
|
197
205
|
insertText,
|
|
198
206
|
type EditResult,
|
|
207
|
+
type EditorRowModel,
|
|
199
208
|
killToLineEnd,
|
|
200
209
|
killToLineStart,
|
|
201
210
|
moveCursorBy,
|
|
@@ -225,9 +234,12 @@ const LOCAL_COMMANDS = [
|
|
|
225
234
|
{ label: '/fork', description: 'fork at the latest completed turn (/fork [event-seq])' },
|
|
226
235
|
{ label: '/resume', description: 'browse or switch root sessions (/resume [id|prefix])' },
|
|
227
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' },
|
|
228
238
|
{ label: '/jobs', description: 'inspect background jobs' },
|
|
239
|
+
{ label: '/schedule', description: 'inspect active reminders (created through schedule tools)' },
|
|
229
240
|
{ label: '/statusline', description: 'customize the status line items' },
|
|
230
241
|
{ label: '/theme', description: 'switch the color theme' },
|
|
242
|
+
{ label: '/animation', description: 'toggle timed animations (/animation [on|off])' },
|
|
231
243
|
{ label: '/history', description: 'search and recall past prompts' },
|
|
232
244
|
{ label: '/agents', description: 'inspect subagent sessions of this conversation' },
|
|
233
245
|
{ label: '/todos', description: 'inspect the full todo list' },
|
|
@@ -277,10 +289,20 @@ export interface AppProps {
|
|
|
277
289
|
mode: string
|
|
278
290
|
/** Permission preset selected for the current or pending first session. */
|
|
279
291
|
permission: string
|
|
280
|
-
/**
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
292
|
+
/**
|
|
293
|
+
* Submit one line: slash commands to the registry, other text to the agent.
|
|
294
|
+
* The optional origin names the session the submission was composed for —
|
|
295
|
+
* an attachment prepare resolves after the app remounted onto another
|
|
296
|
+
* session, and the runner drops the stale delivery then.
|
|
297
|
+
*/
|
|
298
|
+
dispatch(text: string, attachments?: readonly ContentBlock[], origin?: string): void
|
|
299
|
+
/** Submit steering, with the same stale-delivery guard as {@link dispatch}. */
|
|
300
|
+
steer(text: string, attachments?: readonly ContentBlock[], origin?: string): void
|
|
301
|
+
/**
|
|
302
|
+
* The FULL current session identity ('' while the first session is pending)
|
|
303
|
+
* — the stale-delivery origin above. Distinct from the short display id.
|
|
304
|
+
*/
|
|
305
|
+
sessionKey: string
|
|
284
306
|
/** Interrupt the running turn (Esc); true when a turn was cancelled. */
|
|
285
307
|
interrupt(): boolean
|
|
286
308
|
/** Quit: unmount, flush, and request process exit. */
|
|
@@ -293,6 +315,10 @@ export interface AppProps {
|
|
|
293
315
|
inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
|
|
294
316
|
/** Validate, normalize and persist images immediately before submission. */
|
|
295
317
|
prepareImages(paths: readonly string[], signal?: AbortSignal): Promise<readonly ImageBlock[]>
|
|
318
|
+
/** Validate draft non-image file paths without committing attachment objects. */
|
|
319
|
+
inspectFiles(paths: readonly string[]): Promise<readonly FilePathInspection[]>
|
|
320
|
+
/** Persist non-image files immediately before submission as durable file blocks. */
|
|
321
|
+
prepareFiles(paths: readonly string[], signal?: AbortSignal): Promise<readonly FileBlock[]>
|
|
296
322
|
/** Apply one /model selection (with an advertised reasoning effort, when picked); returns the display label. */
|
|
297
323
|
selectModel(row: ModelRow, effortId?: string): string
|
|
298
324
|
/** The /subagent override label, '' when delegated agents follow the current model. */
|
|
@@ -338,8 +364,10 @@ export interface AppProps {
|
|
|
338
364
|
logoutProviderAuthorization?(row: ProviderAuthorizationRow): Promise<void>
|
|
339
365
|
openAuthorizationUrl?(url: string): boolean
|
|
340
366
|
copyTextValue?(text: string): Promise<void>
|
|
341
|
-
/** Cycle to the next
|
|
342
|
-
|
|
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
|
|
343
371
|
/** Select or inspect a permission preset without requiring a pre-existing session. */
|
|
344
372
|
setPermission(id: string): string
|
|
345
373
|
/** Export the transcript to a markdown file (/export [path]); reports via notices. */
|
|
@@ -369,6 +397,10 @@ export interface AppProps {
|
|
|
369
397
|
loadPlugins(): readonly PluginRow[]
|
|
370
398
|
/** Caller-visible background jobs (the host jobs registry, read-only). */
|
|
371
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>
|
|
372
404
|
/** Registers the app's notice channel with the runner (called once on mount). */
|
|
373
405
|
onBridgeReady(bridge: { notify(text: string, tone?: NoticeTone): void }): void
|
|
374
406
|
/** Ordered enabled status items (/statusline config); the runner owns persistence. */
|
|
@@ -377,6 +409,11 @@ export interface AppProps {
|
|
|
377
409
|
saveStatusline(items: readonly string[]): void
|
|
378
410
|
/** Apply and persist one /theme selection; the runner owns the theme.json file. */
|
|
379
411
|
saveTheme?(name: ThemeName): void
|
|
412
|
+
/** Whether timed animations run at startup (animations.json; on by default
|
|
413
|
+
* — like parseAnimationsPref, only an explicit false disables them). */
|
|
414
|
+
animations?: boolean
|
|
415
|
+
/** Apply and persist one /animation toggle; the runner owns the file. */
|
|
416
|
+
saveAnimations?(enabled: boolean): void
|
|
380
417
|
/** Persistent cross-session input history (oldest first); the runner owns the file. */
|
|
381
418
|
history: readonly string[]
|
|
382
419
|
/** Persist one submitted prompt to the global history file. */
|
|
@@ -393,12 +430,23 @@ function padColumns(text: string, width: number): string {
|
|
|
393
430
|
return clipped + ' '.repeat(Math.max(0, width - visibleColumns(clipped)))
|
|
394
431
|
}
|
|
395
432
|
|
|
396
|
-
/**
|
|
433
|
+
/**
|
|
434
|
+
* Wall-clock frame counter for one self-contained animated leaf. Each fire
|
|
435
|
+
* derives the tick from elapsed time instead of counting intervals, so a
|
|
436
|
+
* stretched interval (busy event loop, slow SSH) skips the animation ahead
|
|
437
|
+
* rather than slowing it down; the tick always tracks real time.
|
|
438
|
+
*/
|
|
397
439
|
function useFrames(intervalMs: number, active = true): number {
|
|
398
440
|
const [tick, setTick] = useState(0)
|
|
399
441
|
useEffect(() => {
|
|
400
442
|
if (!active) return
|
|
401
|
-
const
|
|
443
|
+
const startedAt = Date.now()
|
|
444
|
+
setTick(0)
|
|
445
|
+
const id = setInterval(() => {
|
|
446
|
+
// Clock setback (NTP resync) must not produce negative ticks — the
|
|
447
|
+
// blink parity check would flip the caret off for a full period.
|
|
448
|
+
setTick(Math.max(0, Math.floor((Date.now() - startedAt) / intervalMs)))
|
|
449
|
+
}, intervalMs)
|
|
402
450
|
return () => {
|
|
403
451
|
clearInterval(id)
|
|
404
452
|
}
|
|
@@ -421,15 +469,18 @@ function useStableInput(handler: (input: string, key: Key) => void, active: bool
|
|
|
421
469
|
useInput(stableHandler, { isActive: active })
|
|
422
470
|
}
|
|
423
471
|
|
|
424
|
-
/**
|
|
425
|
-
|
|
426
|
-
|
|
472
|
+
/**
|
|
473
|
+
* The original web StateDot chase used by the busy composer marker. With
|
|
474
|
+
* animations off it freezes on the first frame (still visibly busy).
|
|
475
|
+
*/
|
|
476
|
+
function BusyChase({ animated = true }: { animated?: boolean }): ReactElement {
|
|
477
|
+
const tick = useFrames(BUSY_CHASE_TICK_MS, animated)
|
|
427
478
|
return createElement(Text, { color: inkColor(getPalette().brandBright) }, busyChaseFrame(tick) + ' ')
|
|
428
479
|
}
|
|
429
480
|
|
|
430
|
-
/** Blinking block caret appended to streaming text. */
|
|
431
|
-
function Caret(): ReactElement {
|
|
432
|
-
const tick = useFrames(
|
|
481
|
+
/** Blinking block caret appended to streaming text; solid when frozen. */
|
|
482
|
+
function Caret({ animated = true }: { animated?: boolean }): ReactElement {
|
|
483
|
+
const tick = useFrames(CARET_BLINK_TICK_MS, animated)
|
|
433
484
|
return createElement(Text, null, caretVisible(tick) ? '▍' : ' ')
|
|
434
485
|
}
|
|
435
486
|
|
|
@@ -440,7 +491,7 @@ function useCursorBlink(active: boolean): { visible: boolean; reset(): void } {
|
|
|
440
491
|
useEffect(() => {
|
|
441
492
|
setVisible(true)
|
|
442
493
|
if (!active) return
|
|
443
|
-
const id = setInterval(() => setVisible(current => !current),
|
|
494
|
+
const id = setInterval(() => setVisible(current => !current), CARET_BLINK_TICK_MS)
|
|
444
495
|
return () => {
|
|
445
496
|
clearInterval(id)
|
|
446
497
|
}
|
|
@@ -456,10 +507,12 @@ function useCursorBlink(active: boolean): { visible: boolean; reset(): void } {
|
|
|
456
507
|
* One bounded line painted with the deep-diving shimmer: a continuously
|
|
457
508
|
* moving blue gradient across graphemes, the `✻` glyph in the breathing
|
|
458
509
|
* spark color. Shared by the busy line and the collapsed thinking marker;
|
|
459
|
-
* always exactly one row (truncate-end) so the live budget stays exact.
|
|
510
|
+
* always exactly one row (truncate-end) so the live budget stays exact. With
|
|
511
|
+
* animations off the same spans render in fixed colors — no timer, no
|
|
512
|
+
* per-frame repaint, the `✻` keeps its highlight.
|
|
460
513
|
*/
|
|
461
|
-
function ShimmerLine({ text }: { text: string }): ReactElement {
|
|
462
|
-
const tick = useFrames(DEEP_DIVING_SHIMMER_TICK_MS)
|
|
514
|
+
function ShimmerLine({ text, animated = true }: { text: string; animated?: boolean }): ReactElement {
|
|
515
|
+
const tick = useFrames(DEEP_DIVING_SHIMMER_TICK_MS, animated)
|
|
463
516
|
const palette = getPalette()
|
|
464
517
|
const graphemes = splitGraphemes(text)
|
|
465
518
|
return createElement(
|
|
@@ -471,7 +524,11 @@ function ShimmerLine({ text }: { text: string }): ReactElement {
|
|
|
471
524
|
Text,
|
|
472
525
|
{
|
|
473
526
|
key: `${grapheme.start}-${grapheme.end}`,
|
|
474
|
-
color: inkColor(
|
|
527
|
+
color: inkColor(!animated
|
|
528
|
+
? (sparkle ? palette.brandBright : palette.brandDeep)
|
|
529
|
+
: sparkle
|
|
530
|
+
? deepDivingSparkColor(tick, palette.brandDeep, palette.brandBright)
|
|
531
|
+
: deepDivingGradientColor(index, tick, graphemes.length, palette.brandDeep, palette.brandBright)),
|
|
475
532
|
bold: sparkle || undefined,
|
|
476
533
|
},
|
|
477
534
|
grapheme.text,
|
|
@@ -486,10 +543,10 @@ function ShimmerLine({ text }: { text: string }): ReactElement {
|
|
|
486
543
|
* only once the turn has clearly been running (15s) — anchored to `turn/start`
|
|
487
544
|
* so a resumed mid-turn keeps the real time.
|
|
488
545
|
*/
|
|
489
|
-
function DeepDivingLine({ since }: { since: number }): ReactElement {
|
|
546
|
+
function DeepDivingLine({ since, animated = true }: { since: number; animated?: boolean }): ReactElement {
|
|
490
547
|
const elapsed = since === 0 ? 0 : Date.now() - since
|
|
491
548
|
const text = elapsed >= 15_000 ? `✻ Deep diving... ${runClock(elapsed)}` : '✻ Deep diving...'
|
|
492
|
-
return createElement(ShimmerLine, { text })
|
|
549
|
+
return createElement(ShimmerLine, { text, animated })
|
|
493
550
|
}
|
|
494
551
|
|
|
495
552
|
/**
|
|
@@ -883,6 +940,10 @@ function statusToneProps(tone: StatusTone): {
|
|
|
883
940
|
return { color: inkColor(getPalette().brand), bold: undefined, dimColor: undefined }
|
|
884
941
|
case 'success':
|
|
885
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 }
|
|
886
947
|
case 'warn':
|
|
887
948
|
return { color: inkColor(getPalette().warn), bold: true, dimColor: undefined }
|
|
888
949
|
case 'error':
|
|
@@ -924,12 +985,15 @@ function deepseekWaveHues(tier: DeepseekWaveTier): readonly [RgbTriple, RgbTripl
|
|
|
924
985
|
: [palette.brandBright, palette.code, palette.brandMid]
|
|
925
986
|
}
|
|
926
987
|
|
|
927
|
-
function StatusLine({ facts, stats, busy, columns, items }: {
|
|
988
|
+
function StatusLine({ facts, stats, busy, columns, items, onRows }: {
|
|
928
989
|
facts: StatusFacts
|
|
929
990
|
stats: Parameters<typeof layoutStatusBar>[1]
|
|
930
991
|
busy: boolean
|
|
931
992
|
columns: number
|
|
932
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
|
|
933
997
|
}): ReactElement {
|
|
934
998
|
const layout = useMemo(() => layoutStatusBar(facts, stats, Math.max(8, columns - 2), {
|
|
935
999
|
busy,
|
|
@@ -955,6 +1019,13 @@ function StatusLine({ facts, stats, busy, columns, items }: {
|
|
|
955
1019
|
columns,
|
|
956
1020
|
items,
|
|
957
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])
|
|
958
1029
|
|
|
959
1030
|
const renderRow = (row: { left: readonly StatusGroup[]; right: readonly StatusSpan[]; hint: boolean }, key: string, indent = 0): ReactElement => {
|
|
960
1031
|
const leftParts: ReactElement[] = []
|
|
@@ -1593,38 +1664,52 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
|
|
|
1593
1664
|
onRetry(): void
|
|
1594
1665
|
onClose(): void
|
|
1595
1666
|
}): ReactElement {
|
|
1667
|
+
const [query, setQuery] = useState('')
|
|
1596
1668
|
const [cursor, setCursor] = useState(0)
|
|
1597
1669
|
const stdout = useStdout().stdout
|
|
1598
1670
|
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
1599
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])
|
|
1600
1681
|
const positioned = useRef(false)
|
|
1601
1682
|
|
|
1602
1683
|
useEffect(() => {
|
|
1603
1684
|
// Open ON the applied model (Codex resumes the previous pick): the first
|
|
1604
1685
|
// non-empty directory positions the cursor once, never on later refreshes.
|
|
1605
1686
|
if (positioned.current || rows.length === 0 || current === undefined) {
|
|
1606
|
-
if (
|
|
1687
|
+
if (filtered.length === 0) {
|
|
1607
1688
|
if (cursor !== 0) setCursor(0)
|
|
1608
1689
|
return
|
|
1609
1690
|
}
|
|
1610
|
-
if (cursor >=
|
|
1691
|
+
if (cursor >= filtered.length) setCursor(filtered.length - 1)
|
|
1611
1692
|
return
|
|
1612
1693
|
}
|
|
1613
1694
|
const index = rows.findIndex(row => `${row.provider}/${row.model}` === current)
|
|
1614
1695
|
if (index >= 0) {
|
|
1615
1696
|
positioned.current = true
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
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))
|
|
1619
1704
|
}
|
|
1620
|
-
}, [rows, cursor, current])
|
|
1705
|
+
}, [rows, filtered, cursor, current])
|
|
1621
1706
|
|
|
1622
1707
|
useInput((input, key) => {
|
|
1623
|
-
if (key.escape || input === 'q') {
|
|
1708
|
+
if (key.escape || (input === 'q' && query === '')) {
|
|
1624
1709
|
onClose()
|
|
1625
1710
|
return
|
|
1626
1711
|
}
|
|
1627
|
-
if (input === 'r') {
|
|
1712
|
+
if (input === 'r' && query === '') {
|
|
1628
1713
|
onRetry()
|
|
1629
1714
|
return
|
|
1630
1715
|
}
|
|
@@ -1637,13 +1722,19 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
|
|
|
1637
1722
|
onClose()
|
|
1638
1723
|
return
|
|
1639
1724
|
}
|
|
1640
|
-
|
|
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
|
|
1641
1732
|
if (key.upArrow) {
|
|
1642
|
-
setCursor(cursor > 0 ? cursor - 1 :
|
|
1733
|
+
setCursor(cursor > 0 ? cursor - 1 : filtered.length - 1)
|
|
1643
1734
|
return
|
|
1644
1735
|
}
|
|
1645
1736
|
if (key.downArrow) {
|
|
1646
|
-
setCursor(cursor <
|
|
1737
|
+
setCursor(cursor < filtered.length - 1 ? cursor + 1 : 0)
|
|
1647
1738
|
return
|
|
1648
1739
|
}
|
|
1649
1740
|
if (key.pageUp) {
|
|
@@ -1651,32 +1742,27 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
|
|
|
1651
1742
|
return
|
|
1652
1743
|
}
|
|
1653
1744
|
if (key.pageDown) {
|
|
1654
|
-
setCursor(current => Math.min(
|
|
1745
|
+
setCursor(current => Math.min(filtered.length - 1, current + Math.max(1, viewport.bodyRows - 1)))
|
|
1655
1746
|
return
|
|
1656
1747
|
}
|
|
1657
|
-
if (
|
|
1658
|
-
|
|
1659
|
-
return
|
|
1660
|
-
}
|
|
1661
|
-
if (input === 'G') {
|
|
1662
|
-
setCursor(rows.length - 1)
|
|
1663
|
-
return
|
|
1664
|
-
}
|
|
1665
|
-
if (key.return && rows[cursor] !== undefined) {
|
|
1666
|
-
onSelect(rows[cursor])
|
|
1748
|
+
if (key.return && filtered[cursor] !== undefined) {
|
|
1749
|
+
onSelect(filtered[cursor])
|
|
1667
1750
|
}
|
|
1668
1751
|
})
|
|
1669
1752
|
|
|
1670
1753
|
if (viewport.maxHeight === 0 || viewport.compact) {
|
|
1671
1754
|
const providers = onProviders === undefined ? '' : ' · tab providers'
|
|
1672
|
-
const state =
|
|
1755
|
+
const state = filtered.length === 0
|
|
1673
1756
|
? directory === undefined && error === undefined
|
|
1674
1757
|
? 'loading…'
|
|
1675
1758
|
: error !== undefined
|
|
1676
1759
|
? 'error'
|
|
1677
|
-
: 'no models'
|
|
1678
|
-
: `❯ ${
|
|
1679
|
-
|
|
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))
|
|
1680
1766
|
}
|
|
1681
1767
|
|
|
1682
1768
|
const stateRows: ReactElement[] = directory === undefined && error === undefined
|
|
@@ -1697,23 +1783,27 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
|
|
|
1697
1783
|
)]),
|
|
1698
1784
|
...(rows.length === 0
|
|
1699
1785
|
? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, ' no models available')]
|
|
1700
|
-
:
|
|
1786
|
+
: filtered.length === 0
|
|
1787
|
+
? [createElement(Text, { key: 'no-match', dimColor: true, wrap: 'truncate-end' }, truncateColumns(` no models match '${singleLineText(query)}'`, viewport.contentColumns))]
|
|
1788
|
+
: []),
|
|
1701
1789
|
]
|
|
1702
1790
|
// Measurement and rendering share the same physical-row budget: state
|
|
1703
1791
|
// messages consume body rows before selectable entries, as in Codex's
|
|
1704
1792
|
// list-selection views.
|
|
1705
1793
|
const visibleStateRows = stateRows.slice(0, viewport.bodyRows)
|
|
1706
1794
|
const rowBudget = Math.max(0, viewport.bodyRows - visibleStateRows.length)
|
|
1707
|
-
const first = selectionWindow(cursor,
|
|
1708
|
-
const visible = rowBudget === 0 ? [] :
|
|
1795
|
+
const first = selectionWindow(cursor, filtered.length, rowBudget)
|
|
1796
|
+
const visible = rowBudget === 0 ? [] : filtered.slice(first, first + rowBudget)
|
|
1709
1797
|
return createElement(
|
|
1710
1798
|
Box,
|
|
1711
1799
|
{ flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
|
|
1712
|
-
createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(
|
|
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)),
|
|
1713
1803
|
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1714
1804
|
...visibleStateRows,
|
|
1715
1805
|
...visible.map((row) => {
|
|
1716
|
-
const index =
|
|
1806
|
+
const index = filtered.indexOf(row)
|
|
1717
1807
|
const capability = row.inputModalities?.includes('image') === true ? ' · image' : ''
|
|
1718
1808
|
const label = displayText(`${row.providerName} · ${row.modelName}${capability}`)
|
|
1719
1809
|
return createElement(
|
|
@@ -1727,7 +1817,9 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
|
|
|
1727
1817
|
)
|
|
1728
1818
|
}),
|
|
1729
1819
|
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1730
|
-
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns(
|
|
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))),
|
|
1731
1823
|
)
|
|
1732
1824
|
}
|
|
1733
1825
|
|
|
@@ -1908,7 +2000,10 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
|
|
|
1908
2000
|
const manualKeyConfigured = row.credential?.kind === 'facts' && row.credential.configured
|
|
1909
2001
|
const showAuthorization = !manualKeyConfigured || authorization?.record.configured === true || authorization?.inFlight === true
|
|
1910
2002
|
const authLabel = showAuthorization ? ' · ' + providerAuthorizationStatus(authorization) : ''
|
|
1911
|
-
|
|
2003
|
+
// The adapter's configuration diagnostic rides the row (the provider
|
|
2004
|
+
// stays listed and repairable — this is why it did not vanish).
|
|
2005
|
+
const diagnostic = row.diagnostic === undefined ? '' : ' · ! ' + singleLineText(row.diagnostic)
|
|
2006
|
+
const label = identity + ' · ' + providerStateLabel(row) + authLabel + (row.removable ? ' · custom' : '') + diagnostic
|
|
1912
2007
|
// Configured rows render in the intermediate brand blue so the in-use
|
|
1913
2008
|
// group reads at a glance; the dormant tail keeps the dim caption gray.
|
|
1914
2009
|
const idleColor = row.configured ? inkColor(getPalette().brandMid) : inkColor(getPalette().dim)
|
|
@@ -2249,7 +2344,9 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
|
|
|
2249
2344
|
const keyBullets = '•'.repeat(Math.min([...keyDraft].length, Math.max(1, viewport.contentColumns - 14)))
|
|
2250
2345
|
const keyRow = createElement(Text, { key: 'key', color: zone === 'key' ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns((' ' + (zone === 'key' ? '>' : ' ') + ' key ' + keyBullets + (zone === 'key' && !busy ? '▏' : '') + (keyDraft === '' ? ' (' + keyStatus + ')' : busy ? ' saving…' : '')).replace(/ +$/u, ''), viewport.contentColumns))
|
|
2251
2346
|
const urlRow = createElement(Text, { key: 'url', color: zone === 'url' ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(' ' + (zone === 'url' ? '>' : ' ') + ' url ' + (baseURL === '' ? '(official default)' : baseURL) + (zone === 'url' ? '▏' : ''), viewport.contentColumns))
|
|
2252
|
-
|
|
2347
|
+
// The fixed diagnostic row (present only with an adapter error) joins the
|
|
2348
|
+
// same height budget as the state rows — it must never overflow the panel.
|
|
2349
|
+
const rowBudget = Math.max(0, viewport.bodyRows - stateRows.length - (target.diagnostic === undefined ? 0 : 1) - 3)
|
|
2253
2350
|
const first = selectionWindow(cursor, models.length + 1, rowBudget)
|
|
2254
2351
|
const modelRows: ReactElement[] = []
|
|
2255
2352
|
for (let index = first; index < first + Math.max(0, Math.min(models.length + 1 - first, rowBudget)); index += 1) {
|
|
@@ -2271,6 +2368,13 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
|
|
|
2271
2368
|
Box,
|
|
2272
2369
|
{ flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
|
|
2273
2370
|
createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns('/model — configure ' + target.displayName, viewport.contentColumns)),
|
|
2371
|
+
// The adapter's configuration diagnostic heads the editor: the provider
|
|
2372
|
+
// is here precisely because it stayed listed for repair.
|
|
2373
|
+
...(target.diagnostic === undefined ? [] : [createElement(
|
|
2374
|
+
Text,
|
|
2375
|
+
{ key: 'diagnostic', color: inkColor(getPalette().warn), wrap: 'truncate-end' },
|
|
2376
|
+
truncateColumns('! ' + displayText(singleLineText(target.diagnostic)), viewport.contentColumns),
|
|
2377
|
+
)]),
|
|
2274
2378
|
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
2275
2379
|
keyRow,
|
|
2276
2380
|
urlRow,
|
|
@@ -2610,6 +2714,197 @@ function waveRowSpans(cells: readonly ComposerCell[]): ReactElement[] {
|
|
|
2610
2714
|
return spans
|
|
2611
2715
|
}
|
|
2612
2716
|
|
|
2717
|
+
/** Index of the cell STARTING at a display column, if one does. */
|
|
2718
|
+
function cellIndexAtColumn(cells: readonly ComposerCell[], target: number): number | undefined {
|
|
2719
|
+
let column = 0
|
|
2720
|
+
for (let index = 0; index < cells.length; index += 1) {
|
|
2721
|
+
if (column === target) return index
|
|
2722
|
+
column += cells[index]!.width ?? visibleColumns(cells[index]!.char)
|
|
2723
|
+
if (column > target) return undefined
|
|
2724
|
+
}
|
|
2725
|
+
return undefined
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
/**
|
|
2729
|
+
* Wall-clock wave frames — strictly ONE sweep per MOUNT; the mount-spanning
|
|
2730
|
+
* one-shot latch (surviving modal unmounts) lives in Input as `wavePlayedKey`.
|
|
2731
|
+
* The first gate-off after the sweep has started (it completed, a turn went
|
|
2732
|
+
* busy, image preparation began, animations were toggled off) latches `done`
|
|
2733
|
+
* for this mount, so the same mount can never resume or replay. A trigger
|
|
2734
|
+
* that lands while the gate is already down stays pending until the gate
|
|
2735
|
+
* rises once, then plays.
|
|
2736
|
+
*/
|
|
2737
|
+
function useWaveFrames(active: boolean, durationMs: number): { tick: number; done: boolean } {
|
|
2738
|
+
const [tick, setTick] = useState(0)
|
|
2739
|
+
const [done, setDone] = useState(false)
|
|
2740
|
+
const startedRef = useRef(false)
|
|
2741
|
+
useEffect(() => {
|
|
2742
|
+
if (done) return
|
|
2743
|
+
if (!active) {
|
|
2744
|
+
// A sweep that already started is cancelled permanently, never resumed.
|
|
2745
|
+
if (startedRef.current) setDone(true)
|
|
2746
|
+
return
|
|
2747
|
+
}
|
|
2748
|
+
startedRef.current = true
|
|
2749
|
+
const startedAt = Date.now()
|
|
2750
|
+
const id = setInterval(() => {
|
|
2751
|
+
const elapsed = Date.now() - startedAt
|
|
2752
|
+
if (elapsed >= durationMs) {
|
|
2753
|
+
clearInterval(id)
|
|
2754
|
+
setDone(true)
|
|
2755
|
+
return
|
|
2756
|
+
}
|
|
2757
|
+
setTick(Math.max(0, Math.floor(elapsed / DEEPSEEK_WAVE_TICK_MS)))
|
|
2758
|
+
}, DEEPSEEK_WAVE_TICK_MS)
|
|
2759
|
+
return () => {
|
|
2760
|
+
clearInterval(id)
|
|
2761
|
+
}
|
|
2762
|
+
}, [active, durationMs, done])
|
|
2763
|
+
return { tick, done }
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
/** The wave-painted composer band: everything the sweep needs, as data. */
|
|
2767
|
+
interface ComposerWaveProps {
|
|
2768
|
+
/** Wave tier of the applied route (flash / deepseek / unknown). */
|
|
2769
|
+
tier: DeepseekWaveTier
|
|
2770
|
+
/** Ignition style App picked for this trigger. */
|
|
2771
|
+
style: DeepseekWaveStyle
|
|
2772
|
+
/** False while busy, preparing images, or animations are off; the fallback
|
|
2773
|
+
* band renders instead (non-wave routes keep it false permanently). */
|
|
2774
|
+
active: boolean
|
|
2775
|
+
/** The static band to render before, after, and instead of the sweep. */
|
|
2776
|
+
fallback: ReactElement
|
|
2777
|
+
/** Composer band width in columns (terminal width minus the last column). */
|
|
2778
|
+
bandWidth: number
|
|
2779
|
+
/** Ink color of the static band background (the transparent-cell base). */
|
|
2780
|
+
bandBg: string
|
|
2781
|
+
/** The editor's visible physical rows (already windowed). */
|
|
2782
|
+
rows: readonly EditorRowModel[]
|
|
2783
|
+
/** Index of `rows[0]` in the full editor model (keying + caret row math). */
|
|
2784
|
+
windowStart: number
|
|
2785
|
+
/** Absolute caret row in the editor model. */
|
|
2786
|
+
caretRow: number
|
|
2787
|
+
/** The authoritative cursor offset. */
|
|
2788
|
+
cursor: number
|
|
2789
|
+
/** Caret blink visibility (shared with the static path). */
|
|
2790
|
+
caretVisible: boolean
|
|
2791
|
+
/** The draft text (placeholder detection on row 0). */
|
|
2792
|
+
value: string
|
|
2793
|
+
/** Tier prompt glyph and accent color (persistent, like Codex's charge). */
|
|
2794
|
+
promptGlyph: string
|
|
2795
|
+
promptColor: string
|
|
2796
|
+
/** Fires EXACTLY ONCE when this sweep ends for any reason — completed,
|
|
2797
|
+
* cancelled by the gate, or unmounted (a modal panel froze the composer) —
|
|
2798
|
+
* so Input's played-key latch survives the leaf's unmount/remount cycle. */
|
|
2799
|
+
onSettled(): void
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
/**
|
|
2803
|
+
* The self-contained wave leaf: it owns its 33ms tick, so the sweep
|
|
2804
|
+
* re-renders ONLY this component at ~30fps — Input's derived editor state
|
|
2805
|
+
* never re-runs per frame. Graphemes stay atomic and every background sample
|
|
2806
|
+
* advances by terminal display columns, so CJK and emoji cannot move the
|
|
2807
|
+
* caret or wrap the band. The duration gate renders the fallback band on the
|
|
2808
|
+
* frame the sweep completes.
|
|
2809
|
+
*/
|
|
2810
|
+
function ComposerWave(props: ComposerWaveProps): ReactElement {
|
|
2811
|
+
const { tier, style } = props
|
|
2812
|
+
const durationMs = deepseekWaveDuration(tier, style)
|
|
2813
|
+
const { tick, done } = useWaveFrames(props.active, durationMs)
|
|
2814
|
+
// Report the sweep's end exactly once — completion, gate cancellation, or
|
|
2815
|
+
// unmount (a modal opened and froze the composer) — latching Input's
|
|
2816
|
+
// played-key so this trigger can never replay after a remount.
|
|
2817
|
+
const settledRef = useRef(false)
|
|
2818
|
+
const onSettledRef = useRef(props.onSettled)
|
|
2819
|
+
onSettledRef.current = props.onSettled
|
|
2820
|
+
const settle = (): void => {
|
|
2821
|
+
if (settledRef.current) return
|
|
2822
|
+
settledRef.current = true
|
|
2823
|
+
onSettledRef.current()
|
|
2824
|
+
}
|
|
2825
|
+
useEffect(() => {
|
|
2826
|
+
if (done) settle()
|
|
2827
|
+
}, [done])
|
|
2828
|
+
useEffect(() => () => {
|
|
2829
|
+
settle()
|
|
2830
|
+
}, [])
|
|
2831
|
+
if (!props.active || done || tick * DEEPSEEK_WAVE_TICK_MS >= durationMs) return props.fallback
|
|
2832
|
+
const hues = deepseekWaveHues(tier)
|
|
2833
|
+
const bandRgb = getPalette().composerBand
|
|
2834
|
+
const totalBandRows = props.rows.length + 2
|
|
2835
|
+
const waveBg = (row: number, column: number): string => {
|
|
2836
|
+
const rgb = deepseekWaveColumnBg(tick, column, props.bandWidth, tier, style, hues, bandRgb, row, totalBandRows)
|
|
2837
|
+
return rgb === null ? props.bandBg : inkColor(rgb)
|
|
2838
|
+
}
|
|
2839
|
+
const blankBandRow = (row: number): ReactElement => {
|
|
2840
|
+
const blanks: ComposerCell[] = []
|
|
2841
|
+
for (let column = 0; column < props.bandWidth; column += 1) {
|
|
2842
|
+
blanks.push({ char: ' ', width: 1, backgroundColor: waveBg(row, column) })
|
|
2843
|
+
}
|
|
2844
|
+
return createElement(Text, { key: `blank-${row}` }, ...waveRowSpans(blanks))
|
|
2845
|
+
}
|
|
2846
|
+
const editorWaveRows = props.rows.map((row, visibleIndex) => {
|
|
2847
|
+
const sourceIndex = props.windowStart + visibleIndex
|
|
2848
|
+
const bandRow = visibleIndex + 1
|
|
2849
|
+
const parts = editorRowParts(row, sourceIndex, props.caretRow, props.cursor)
|
|
2850
|
+
const placeholder = sourceIndex === 0 && props.value === ''
|
|
2851
|
+
const cells: ComposerCell[] = []
|
|
2852
|
+
let usedColumns = 0
|
|
2853
|
+
const push = (char: string, extra: Omit<ComposerCell, 'char' | 'width' | 'backgroundColor'> = {}): void => {
|
|
2854
|
+
const width = visibleColumns(char)
|
|
2855
|
+
cells.push({ char, width, backgroundColor: waveBg(bandRow, usedColumns), ...extra })
|
|
2856
|
+
usedColumns += width
|
|
2857
|
+
}
|
|
2858
|
+
if (sourceIndex === 0) {
|
|
2859
|
+
push(props.promptGlyph, { color: props.promptColor, bold: true })
|
|
2860
|
+
push(' ', { color: props.promptColor })
|
|
2861
|
+
} else {
|
|
2862
|
+
push(' ')
|
|
2863
|
+
push(' ')
|
|
2864
|
+
}
|
|
2865
|
+
for (const span of splitGraphemes(parts.before)) push(span.text)
|
|
2866
|
+
if (parts.hasCaret) push(parts.caret, { inverse: props.caretVisible })
|
|
2867
|
+
const tail = placeholder ? COMPOSER_PLACEHOLDER : parts.after
|
|
2868
|
+
for (const span of splitGraphemes(tail)) push(span.text, placeholder ? { dim: true } : {})
|
|
2869
|
+
while (usedColumns < props.bandWidth) push(' ')
|
|
2870
|
+
|
|
2871
|
+
const middleBandRow = Math.floor(totalBandRows / 2)
|
|
2872
|
+
if (bandRow === middleBandRow && deepseekWaveWordVisible(tick, tier, style)) {
|
|
2873
|
+
const word = tier === 'unknown' ? 'Into the Unknown' : 'deepseek'
|
|
2874
|
+
const start = Math.max(2, Math.floor((props.bandWidth - word.length) / 2))
|
|
2875
|
+
const indices = Array.from({ length: word.length }, (_, at) => cellIndexAtColumn(cells, start + at))
|
|
2876
|
+
if (indices.every(index => index !== undefined && (cells[index]!.char === ' ' || cells[index]!.dim === true))) {
|
|
2877
|
+
for (let at = 0; at < word.length; at += 1) {
|
|
2878
|
+
const cell = cells[indices[at]!]!
|
|
2879
|
+
cell.char = word[at]!
|
|
2880
|
+
cell.width = 1
|
|
2881
|
+
cell.color = inkColor(deepseekWaveWordHue(at, hues))
|
|
2882
|
+
cell.bold = true
|
|
2883
|
+
cell.dim = false
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
if (bandRow === middleBandRow && (tier === 'deepseek' || tier === 'unknown') && style === 'wave') {
|
|
2888
|
+
const spark = deepseekWaveSpark(tick)
|
|
2889
|
+
const lastIndex = cellIndexAtColumn(cells, props.bandWidth - 1)
|
|
2890
|
+
if (spark !== null && lastIndex !== undefined && cells[lastIndex]!.char === ' ') {
|
|
2891
|
+
cells[lastIndex]!.char = spark
|
|
2892
|
+
cells[lastIndex]!.color = props.promptColor
|
|
2893
|
+
cells[lastIndex]!.bold = true
|
|
2894
|
+
cells[lastIndex]!.dim = false
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
return createElement(Text, { key: `editor-${sourceIndex}`, wrap: 'truncate-end' }, ...waveRowSpans(cells))
|
|
2898
|
+
})
|
|
2899
|
+
return createElement(
|
|
2900
|
+
Box,
|
|
2901
|
+
{ flexDirection: 'column', width: props.bandWidth },
|
|
2902
|
+
blankBandRow(0),
|
|
2903
|
+
...editorWaveRows,
|
|
2904
|
+
blankBandRow(totalBandRows - 1),
|
|
2905
|
+
)
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2613
2908
|
/**
|
|
2614
2909
|
* The Ctrl+O transcript inspector: one selected durable entry at a time,
|
|
2615
2910
|
* with independent history selection and content scrolling. The complete
|
|
@@ -2821,8 +3116,12 @@ export function completionCandidates(
|
|
|
2821
3116
|
seen.add(name)
|
|
2822
3117
|
all.push(candidate)
|
|
2823
3118
|
}
|
|
2824
|
-
|
|
2825
|
-
|
|
3119
|
+
// Fuzzy ranking (the web menu's discovery feel): the query must be a
|
|
3120
|
+
// case-insensitive ordered subsequence of a name; prefix hits first, then
|
|
3121
|
+
// alignment score, then this composition order. An empty query keeps the
|
|
3122
|
+
// full list.
|
|
3123
|
+
return rankByName(all.map(candidate => ({ name: candidate.label.slice(1), candidate })), prefix)
|
|
3124
|
+
.map(entry => entry.candidate)
|
|
2826
3125
|
}
|
|
2827
3126
|
|
|
2828
3127
|
/**
|
|
@@ -2923,20 +3222,31 @@ interface DraftImage extends ImagePathInspection {
|
|
|
2923
3222
|
readonly marker: string
|
|
2924
3223
|
}
|
|
2925
3224
|
|
|
3225
|
+
/** One attached non-image file held in the editor until submission persists it. */
|
|
3226
|
+
interface DraftFile extends FilePathInspection {
|
|
3227
|
+
/** Visible draft token; deleting it also detaches the hidden path. */
|
|
3228
|
+
readonly marker: string
|
|
3229
|
+
}
|
|
3230
|
+
|
|
2926
3231
|
/**
|
|
2927
3232
|
* The prompt box: TUI-local slash commands handled locally, other lines
|
|
2928
3233
|
* dispatched; input editing keeps a cursor with history and completion.
|
|
2929
3234
|
* While a modal (approval / question / model panel) owns the keys, the
|
|
2930
3235
|
* box passes every key through untouched.
|
|
2931
3236
|
*/
|
|
2932
|
-
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,
|
|
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 }: {
|
|
2933
3238
|
active: boolean
|
|
2934
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
|
|
2935
3243
|
busy: boolean
|
|
2936
3244
|
descriptors: readonly CommandDescriptor[]
|
|
2937
3245
|
skills: readonly SkillRow[]
|
|
2938
|
-
dispatch(text: string,
|
|
2939
|
-
steer(text: string,
|
|
3246
|
+
dispatch(text: string, attachments?: readonly ContentBlock[], origin?: string): void
|
|
3247
|
+
steer(text: string, attachments?: readonly ContentBlock[], origin?: string): void
|
|
3248
|
+
/** The full current session identity ('' while pending); the delivery origin. */
|
|
3249
|
+
sessionKey: string
|
|
2940
3250
|
interrupt(): boolean
|
|
2941
3251
|
quit(): void
|
|
2942
3252
|
openModel(): void
|
|
@@ -2946,6 +3256,10 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2946
3256
|
openPermission(): void
|
|
2947
3257
|
openResume(): void
|
|
2948
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
|
|
2949
3263
|
openJobs(): void
|
|
2950
3264
|
openStatusline(): void
|
|
2951
3265
|
openTheme(): void
|
|
@@ -2981,7 +3295,9 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2981
3295
|
loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
|
|
2982
3296
|
inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
|
|
2983
3297
|
prepareImages(paths: readonly string[], signal?: AbortSignal): Promise<readonly ImageBlock[]>
|
|
2984
|
-
|
|
3298
|
+
inspectFiles(paths: readonly string[]): Promise<readonly FilePathInspection[]>
|
|
3299
|
+
prepareFiles(paths: readonly string[], signal?: AbortSignal): Promise<readonly FileBlock[]>
|
|
3300
|
+
cycleMode(): string
|
|
2985
3301
|
exportTranscript(argument: string): Promise<void>
|
|
2986
3302
|
renameTitle(argument: string): string
|
|
2987
3303
|
copyLastResponse(): Promise<string>
|
|
@@ -2999,6 +3315,10 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2999
3315
|
historyFill: { text: string; index: number } | undefined
|
|
3000
3316
|
/** Marks the accepted entry consumed (called after the fill is applied). */
|
|
3001
3317
|
historyConsumed(): void
|
|
3318
|
+
/** Whether timed animations run (shimmer, chase, blink, wave). */
|
|
3319
|
+
animations: boolean
|
|
3320
|
+
/** Apply and report one /animation toggle (App persists through the runner). */
|
|
3321
|
+
applyAnimations(enabled: boolean): void
|
|
3002
3322
|
/** DeepSeek easter-egg wave tier of the applied route (null otherwise):
|
|
3003
3323
|
* official DeepSeek models drive their flash/pro tiers, non-DeepSeek
|
|
3004
3324
|
* models running an effort above high drive the "Into the Unknown"
|
|
@@ -3009,14 +3329,25 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3009
3329
|
waveStyle: DeepseekWaveStyle | null
|
|
3010
3330
|
/** Maximum physical editor rows the composer may occupy (see composerMaxRows). */
|
|
3011
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
|
|
3012
3339
|
/** Reports the editor's current physical row count so the live budget stays exact. */
|
|
3013
3340
|
onEditorRows(rows: number): void
|
|
3014
3341
|
/** Reports the open completion menu's physical row count (0 when closed)
|
|
3015
3342
|
* for the same reason: the dynamic budget must reserve it, not overflow. */
|
|
3016
3343
|
onMenuRows(rows: number): void
|
|
3017
3344
|
}): ReactElement {
|
|
3018
|
-
const
|
|
3019
|
-
const
|
|
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
|
|
3020
3351
|
const editorColumns = Math.max(1, columns - 6)
|
|
3021
3352
|
const stdin = useStdin().stdin
|
|
3022
3353
|
const focusReporting = isVsCodeTerminalEnv()
|
|
@@ -3029,10 +3360,13 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3029
3360
|
const [draftImages, setDraftImages] = useState<readonly DraftImage[]>([])
|
|
3030
3361
|
const draftImagesRef = useRef(draftImages)
|
|
3031
3362
|
draftImagesRef.current = draftImages
|
|
3363
|
+
const [draftFiles, setDraftFiles] = useState<readonly DraftFile[]>([])
|
|
3364
|
+
const draftFilesRef = useRef(draftFiles)
|
|
3365
|
+
draftFilesRef.current = draftFiles
|
|
3032
3366
|
const [preparingImages, setPreparingImages] = useState(false)
|
|
3033
3367
|
const prepareAbortRef = useRef<AbortController | undefined>(undefined)
|
|
3034
3368
|
const prepareEpochRef = useRef(0)
|
|
3035
|
-
const { visible: cursorVisible, reset: resetCursorBlink } = useCursorBlink(active && !frozen && !preparingImages)
|
|
3369
|
+
const { visible: cursorVisible, reset: resetCursorBlink } = useCursorBlink(active && !frozen && !preparingImages && animations)
|
|
3036
3370
|
useEffect(() => () => {
|
|
3037
3371
|
prepareEpochRef.current += 1
|
|
3038
3372
|
prepareAbortRef.current?.abort()
|
|
@@ -3066,6 +3400,8 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3066
3400
|
const safe = sanitizeDraftText(historyFill.text)
|
|
3067
3401
|
draftImagesRef.current = []
|
|
3068
3402
|
setDraftImages([])
|
|
3403
|
+
draftFilesRef.current = []
|
|
3404
|
+
setDraftFiles([])
|
|
3069
3405
|
valueRef.current = safe
|
|
3070
3406
|
cursorRef.current = safe.length
|
|
3071
3407
|
setValue(safe)
|
|
@@ -3088,6 +3424,11 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3088
3424
|
draftImagesRef.current = next
|
|
3089
3425
|
return next.length === current.length ? current : next
|
|
3090
3426
|
})
|
|
3427
|
+
setDraftFiles((current) => {
|
|
3428
|
+
const next = current.filter(file => value.includes(file.marker))
|
|
3429
|
+
draftFilesRef.current = next
|
|
3430
|
+
return next.length === current.length ? current : next
|
|
3431
|
+
})
|
|
3091
3432
|
}, [value])
|
|
3092
3433
|
|
|
3093
3434
|
// Home/End and the Backspace-vs-Delete family never survive Ink's parser
|
|
@@ -3108,6 +3449,14 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3108
3449
|
const input = focusReporting
|
|
3109
3450
|
? stripTerminalFocusEvents(normalized, focused => {
|
|
3110
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
|
+
}
|
|
3111
3460
|
})
|
|
3112
3461
|
: normalized
|
|
3113
3462
|
rawEditorTokens.current = tokenizeRawEditorChunk(input)
|
|
@@ -3150,13 +3499,19 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3150
3499
|
process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right
|
|
3151
3500
|
)
|
|
3152
3501
|
|
|
3153
|
-
const uniqueImageMarker = (name: string, source: 'mention' | 'drop', reserved: readonly string[] = []): string => {
|
|
3502
|
+
const uniqueImageMarker = (name: string, source: 'mention' | 'drop', reserved: readonly string[] = [], kind: 'image' | 'file' = 'image'): string => {
|
|
3154
3503
|
const safeName = singleLineText(sanitizeDraftText(name))
|
|
3155
|
-
const
|
|
3504
|
+
const label = kind === 'file' ? 'file' : 'image'
|
|
3505
|
+
const base = source === 'mention' ? `@${safeName}` : `[${label}: ${safeName}]`
|
|
3156
3506
|
let marker = base
|
|
3157
3507
|
let suffix = 2
|
|
3158
|
-
|
|
3159
|
-
|
|
3508
|
+
const taken = (candidate: string): boolean =>
|
|
3509
|
+
valueRef.current.includes(candidate)
|
|
3510
|
+
|| draftImagesRef.current.some(image => image.marker === candidate)
|
|
3511
|
+
|| draftFilesRef.current.some(file => file.marker === candidate)
|
|
3512
|
+
|| reserved.includes(candidate)
|
|
3513
|
+
while (taken(marker)) {
|
|
3514
|
+
marker = source === 'mention' ? `@${safeName} (${suffix})` : `[${label}: ${safeName} ${suffix}]`
|
|
3160
3515
|
suffix += 1
|
|
3161
3516
|
}
|
|
3162
3517
|
return marker
|
|
@@ -3173,27 +3528,44 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3173
3528
|
return true
|
|
3174
3529
|
}
|
|
3175
3530
|
|
|
3176
|
-
|
|
3531
|
+
/**
|
|
3532
|
+
* Attach a paste/drop split into image and non-image paths: images ride the
|
|
3533
|
+
* durable image blocks, files ride the 0.1.5 file blocks, and both register
|
|
3534
|
+
* visible draft markers anchored at the drop point.
|
|
3535
|
+
*/
|
|
3536
|
+
const insertDroppedAttachments = (imagePaths: readonly string[], filePaths: readonly string[]): void => {
|
|
3177
3537
|
const originalValue = valueRef.current
|
|
3178
3538
|
const originalCursor = cursorRef.current
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3539
|
+
const total = imagePaths.length + filePaths.length
|
|
3540
|
+
if (total === 0) return
|
|
3541
|
+
notify(`checking ${total} attachment${total === 1 ? '' : 's'}…`)
|
|
3542
|
+
void Promise.all([
|
|
3543
|
+
imagePaths.length === 0 ? Promise.resolve([]) : inspectImages(imagePaths),
|
|
3544
|
+
filePaths.length === 0 ? Promise.resolve([]) : inspectFiles(filePaths),
|
|
3545
|
+
]).then(([inspectedImages, inspectedFiles]) => {
|
|
3546
|
+
const imageAdditions: DraftImage[] = []
|
|
3547
|
+
const fileAdditions: DraftFile[] = []
|
|
3182
3548
|
const markers: string[] = []
|
|
3183
|
-
for (const inspection of
|
|
3184
|
-
if ([...draftImagesRef.current, ...
|
|
3549
|
+
for (const inspection of inspectedImages) {
|
|
3550
|
+
if ([...draftImagesRef.current, ...imageAdditions].some(image => sameImagePath(image.path, inspection.path))) continue
|
|
3185
3551
|
const marker = uniqueImageMarker(inspection.name, 'drop', markers)
|
|
3186
|
-
|
|
3552
|
+
imageAdditions.push({ ...inspection, marker })
|
|
3187
3553
|
markers.push(marker)
|
|
3188
3554
|
}
|
|
3189
|
-
|
|
3190
|
-
|
|
3555
|
+
for (const inspection of inspectedFiles) {
|
|
3556
|
+
if ([...draftFilesRef.current, ...fileAdditions].some(file => sameImagePath(file.path, inspection.path))) continue
|
|
3557
|
+
const marker = uniqueImageMarker(inspection.name, 'drop', markers, 'file')
|
|
3558
|
+
fileAdditions.push({ ...inspection, marker })
|
|
3559
|
+
markers.push(marker)
|
|
3560
|
+
}
|
|
3561
|
+
if (imageAdditions.length === 0 && fileAdditions.length === 0) {
|
|
3562
|
+
notify('those attachments are already attached', 'warning')
|
|
3191
3563
|
return
|
|
3192
3564
|
}
|
|
3193
3565
|
const current = valueRef.current
|
|
3194
3566
|
const anchor = remapStableRange(originalValue, current, { start: originalCursor, end: originalCursor })
|
|
3195
3567
|
if (anchor === undefined) {
|
|
3196
|
-
notify('draft changed at the
|
|
3568
|
+
notify('draft changed at the attachment drop point; drop the files again', 'warning')
|
|
3197
3569
|
return
|
|
3198
3570
|
}
|
|
3199
3571
|
const at = anchor.start
|
|
@@ -3207,12 +3579,16 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3207
3579
|
setValue(edit.value)
|
|
3208
3580
|
setCursor(nextCursor)
|
|
3209
3581
|
resetCursorBlink()
|
|
3210
|
-
const nextImages = [...draftImagesRef.current, ...
|
|
3582
|
+
const nextImages = [...draftImagesRef.current, ...imageAdditions]
|
|
3211
3583
|
draftImagesRef.current = nextImages
|
|
3212
3584
|
setDraftImages(nextImages)
|
|
3213
|
-
|
|
3585
|
+
const nextFiles = [...draftFilesRef.current, ...fileAdditions]
|
|
3586
|
+
draftFilesRef.current = nextFiles
|
|
3587
|
+
setDraftFiles(nextFiles)
|
|
3588
|
+
const count = imageAdditions.length + fileAdditions.length
|
|
3589
|
+
notify(`${count} attachment${count === 1 ? '' : 's'} ready for the next message`)
|
|
3214
3590
|
}, (reason: unknown) => {
|
|
3215
|
-
notify(`
|
|
3591
|
+
notify(`attachment failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
|
|
3216
3592
|
})
|
|
3217
3593
|
}
|
|
3218
3594
|
|
|
@@ -3253,8 +3629,21 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3253
3629
|
const visibleMentionRows = mentionToken !== undefined && isPathLikeMentionQuery(mentionToken.query)
|
|
3254
3630
|
? mentionRows.filter(row => row.kind !== 'session')
|
|
3255
3631
|
: mentionRows
|
|
3632
|
+
// Fuzzy ordering over the upstream candidates (≤20 per page, cheaper than
|
|
3633
|
+
// the slash menu): rows whose name contains the typed query as an ordered
|
|
3634
|
+
// subsequence rise to the top by alignment, and every other upstream row
|
|
3635
|
+
// keeps its place after them — the upstream matcher has its own relevance
|
|
3636
|
+
// semantics (path segments), so ranking reorders but never drops rows. A
|
|
3637
|
+
// path-like query keeps the upstream order entirely.
|
|
3638
|
+
let rankedMentionRows = visibleMentionRows
|
|
3639
|
+
if (mentionToken !== undefined && !isPathLikeMentionQuery(mentionToken.query) && mentionToken.query !== '') {
|
|
3640
|
+
const hits = rankByName(visibleMentionRows.map(row => ({ name: row.label.replace(/^@/u, ''), row })), mentionToken.query)
|
|
3641
|
+
.map(entry => entry.row)
|
|
3642
|
+
const hitSet = new Set(hits)
|
|
3643
|
+
rankedMentionRows = [...hits, ...visibleMentionRows.filter(row => !hitSet.has(row))]
|
|
3644
|
+
}
|
|
3256
3645
|
const menuRows: readonly CompletionCandidate[] = mentionActive
|
|
3257
|
-
?
|
|
3646
|
+
? rankedMentionRows.map(row => ({
|
|
3258
3647
|
label: row.label.startsWith('@')
|
|
3259
3648
|
? row.label
|
|
3260
3649
|
: `@${row.label}${row.kind === 'directory' ? '/' : ''}`,
|
|
@@ -3270,8 +3659,8 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3270
3659
|
/** Accept the highlighted completion-menu candidate into the draft. */
|
|
3271
3660
|
const acceptMenuCandidate = (): void => {
|
|
3272
3661
|
if (mentionActive && mentionToken !== undefined) {
|
|
3273
|
-
if (
|
|
3274
|
-
const row =
|
|
3662
|
+
if (rankedMentionRows.length === 0) return
|
|
3663
|
+
const row = rankedMentionRows[completionIndex % rankedMentionRows.length]
|
|
3275
3664
|
if (row !== undefined) {
|
|
3276
3665
|
if (row.kind === 'file' && row.path !== undefined && looksLikeImagePath(row.path)) {
|
|
3277
3666
|
const tokenText = value.slice(mentionToken.start, cursor)
|
|
@@ -3416,20 +3805,16 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3416
3805
|
notify('image submission cancelled', 'warning')
|
|
3417
3806
|
}
|
|
3418
3807
|
|
|
3419
|
-
/**
|
|
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. */
|
|
3420
3811
|
const navigateVertical = (direction: -1 | 1): void => {
|
|
3421
3812
|
const currentValue = valueRef.current
|
|
3422
3813
|
const currentCursor = cursorRef.current
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
cursorRef.current = next
|
|
3428
|
-
setCursor(next)
|
|
3429
|
-
resetCursorBlink()
|
|
3430
|
-
preferredColumnRef.current = preferred
|
|
3431
|
-
return
|
|
3432
|
-
}
|
|
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.
|
|
3433
3818
|
if (recall.current.entries.length > 0
|
|
3434
3819
|
&& shouldRecallNavigate(currentValue, currentCursor, recall.current.lastRecalled, direction)) {
|
|
3435
3820
|
const step = direction < 0 ? recallOlder(recall.current, currentValue) : recallNewer(recall.current)
|
|
@@ -3441,10 +3826,24 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3441
3826
|
setValue(safe)
|
|
3442
3827
|
setCursor(safe.length)
|
|
3443
3828
|
preferredColumnRef.current = null
|
|
3444
|
-
|
|
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)
|
|
3445
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
|
|
3446
3846
|
}
|
|
3447
|
-
resetCursorBlink()
|
|
3448
3847
|
}
|
|
3449
3848
|
|
|
3450
3849
|
useStableInput((input, key) => {
|
|
@@ -3471,11 +3870,12 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3471
3870
|
}
|
|
3472
3871
|
return
|
|
3473
3872
|
}
|
|
3474
|
-
// Shift+Tab cycles the permission
|
|
3873
|
+
// Shift+Tab cycles the mode stations: permission presets, then the
|
|
3874
|
+
// plan station when the composition offers it (Claude-Code convention).
|
|
3475
3875
|
if (key.tab && key.shift) {
|
|
3476
3876
|
try {
|
|
3477
|
-
const
|
|
3478
|
-
if (
|
|
3877
|
+
const label = cycleMode()
|
|
3878
|
+
if (label !== '') notify(label)
|
|
3479
3879
|
} catch (error: unknown) {
|
|
3480
3880
|
notify(`permission change failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
3481
3881
|
}
|
|
@@ -3510,6 +3910,8 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3510
3910
|
resetCursorBlink()
|
|
3511
3911
|
draftImagesRef.current = []
|
|
3512
3912
|
setDraftImages([])
|
|
3913
|
+
draftFilesRef.current = []
|
|
3914
|
+
setDraftFiles([])
|
|
3513
3915
|
setCompletionIndex(0)
|
|
3514
3916
|
setDismissedMenuValue(undefined)
|
|
3515
3917
|
} else {
|
|
@@ -3578,15 +3980,29 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3578
3980
|
// completion-inserted trailing space still routes `/quit ` correctly.
|
|
3579
3981
|
const trimmed = liveValue.trim()
|
|
3580
3982
|
const text = submissionPayload(liveValue)
|
|
3581
|
-
if (draftImagesRef.current.length > 0) {
|
|
3983
|
+
if (draftImagesRef.current.length > 0 || draftFilesRef.current.length > 0) {
|
|
3984
|
+
// Slash semantics with attachments are unchanged: commands cannot
|
|
3985
|
+
// carry attachments, so the line goes to the model as a prompt —
|
|
3986
|
+
// warn instead of surprising the user with a literal "/export".
|
|
3987
|
+
if (isSlashLine(text)) notify('commands cannot carry attachments; the line will be sent to the model as a prompt', 'warning')
|
|
3988
|
+
// Attachment prepares resolve asynchronously; the app remounts onto
|
|
3989
|
+
// another session in the meantime, and this (old) instance's unmount
|
|
3990
|
+
// cleanup runs too late on the microtask timeline. Tag the delivery
|
|
3991
|
+
// with the composing session so the runner can drop the stale one.
|
|
3992
|
+
const originSession = sessionKey
|
|
3582
3993
|
const controller = new AbortController()
|
|
3583
3994
|
const epoch = prepareEpochRef.current + 1
|
|
3584
3995
|
prepareEpochRef.current = epoch
|
|
3585
3996
|
prepareAbortRef.current = controller
|
|
3586
3997
|
setPreparingImages(true)
|
|
3587
|
-
|
|
3588
|
-
const
|
|
3589
|
-
|
|
3998
|
+
const imageSnapshot = draftImagesRef.current
|
|
3999
|
+
const fileSnapshot = draftFilesRef.current
|
|
4000
|
+
const total = imageSnapshot.length + fileSnapshot.length
|
|
4001
|
+
notify(`processing ${total} attachment${total === 1 ? '' : 's'}…`)
|
|
4002
|
+
void Promise.all([
|
|
4003
|
+
imageSnapshot.length === 0 ? Promise.resolve([]) : prepareImages(imageSnapshot.map(image => image.path), controller.signal),
|
|
4004
|
+
fileSnapshot.length === 0 ? Promise.resolve([]) : prepareFiles(fileSnapshot.map(file => file.path), controller.signal),
|
|
4005
|
+
]).then(([images, files]) => {
|
|
3590
4006
|
if (controller.signal.aborted || prepareEpochRef.current !== epoch) return
|
|
3591
4007
|
prepareAbortRef.current = undefined
|
|
3592
4008
|
setPreparingImages(false)
|
|
@@ -3596,6 +4012,8 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3596
4012
|
setCursor(0)
|
|
3597
4013
|
draftImagesRef.current = []
|
|
3598
4014
|
setDraftImages([])
|
|
4015
|
+
draftFilesRef.current = []
|
|
4016
|
+
setDraftFiles([])
|
|
3599
4017
|
setCompletionIndex(0)
|
|
3600
4018
|
setDismissedMenuValue(undefined)
|
|
3601
4019
|
dismissNotice()
|
|
@@ -3604,13 +4022,14 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3604
4022
|
recordHistory(text)
|
|
3605
4023
|
}
|
|
3606
4024
|
recall.current = beginRecall(recallSpace, '')
|
|
3607
|
-
|
|
3608
|
-
|
|
4025
|
+
const blocks: readonly ContentBlock[] = [...images, ...files]
|
|
4026
|
+
if (busy) steer(text, blocks, originSession)
|
|
4027
|
+
else dispatch(text, blocks, originSession)
|
|
3609
4028
|
}, (reason: unknown) => {
|
|
3610
4029
|
if (controller.signal.aborted || prepareEpochRef.current !== epoch) return
|
|
3611
4030
|
prepareAbortRef.current = undefined
|
|
3612
4031
|
setPreparingImages(false)
|
|
3613
|
-
notify(`
|
|
4032
|
+
notify(`attachment submission failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
|
|
3614
4033
|
})
|
|
3615
4034
|
return
|
|
3616
4035
|
}
|
|
@@ -3623,13 +4042,12 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3623
4042
|
setDismissedMenuValue(undefined)
|
|
3624
4043
|
if (trimmed === '') return
|
|
3625
4044
|
dismissNotice()
|
|
3626
|
-
// Global recall records
|
|
3627
|
-
// commands,
|
|
3628
|
-
// the submission resets any active recall
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
}
|
|
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)
|
|
3633
4051
|
recall.current = beginRecall(recallSpace, '')
|
|
3634
4052
|
if (text === '/quit') {
|
|
3635
4053
|
quit()
|
|
@@ -3721,6 +4139,14 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3721
4139
|
openPlugin(text.slice(7).trim())
|
|
3722
4140
|
return
|
|
3723
4141
|
}
|
|
4142
|
+
if (text === '/update') {
|
|
4143
|
+
openUpdate()
|
|
4144
|
+
return
|
|
4145
|
+
}
|
|
4146
|
+
if (text === '/schedule') {
|
|
4147
|
+
openSchedule()
|
|
4148
|
+
return
|
|
4149
|
+
}
|
|
3724
4150
|
if (text === '/jobs' || text.startsWith('/jobs ')) {
|
|
3725
4151
|
openJobs()
|
|
3726
4152
|
return
|
|
@@ -3733,6 +4159,13 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3733
4159
|
openTheme()
|
|
3734
4160
|
return
|
|
3735
4161
|
}
|
|
4162
|
+
if (text === '/animation' || text.startsWith('/animation ')) {
|
|
4163
|
+
const parsed = parseAnimationsArgument(text.slice('/animation'.length))
|
|
4164
|
+
if (parsed === 'toggle') applyAnimations(!animations)
|
|
4165
|
+
else if (parsed === 'usage') notify('usage: /animation [on|off]', 'info')
|
|
4166
|
+
else applyAnimations(parsed.enabled)
|
|
4167
|
+
return
|
|
4168
|
+
}
|
|
3736
4169
|
if (text === '/history') {
|
|
3737
4170
|
openHistory()
|
|
3738
4171
|
return
|
|
@@ -3906,50 +4339,44 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3906
4339
|
text = text.replaceAll(PASTE_END_MARKER, '')
|
|
3907
4340
|
}
|
|
3908
4341
|
if (text === '') return
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
4342
|
+
if (text.length > 1) {
|
|
4343
|
+
// A path-list paste splits into images and files; prose falls through
|
|
4344
|
+
// as ordinary text (the splitter returns empty groups for non-paths).
|
|
4345
|
+
const dropped = parsePastedAttachmentPaths(text)
|
|
4346
|
+
if (dropped.images.length > 0 || dropped.files.length > 0) {
|
|
4347
|
+
insertDroppedAttachments(dropped.images, dropped.files)
|
|
4348
|
+
return
|
|
4349
|
+
}
|
|
3913
4350
|
}
|
|
3914
4351
|
applyEdit(insertText(valueRef.current, cursorRef.current, text))
|
|
3915
4352
|
}
|
|
3916
4353
|
}, active)
|
|
3917
4354
|
|
|
3918
|
-
// The DeepSeek easter-egg wave
|
|
3919
|
-
// the
|
|
3920
|
-
//
|
|
3921
|
-
//
|
|
3922
|
-
//
|
|
3923
|
-
// pair always differs when a
|
|
3924
|
-
//
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
}
|
|
3937
|
-
}, [waveTier, waveStyle])
|
|
3938
|
-
const waveActive = !preparingImages && waveTick !== null && waveTier !== null && waveStyle !== null
|
|
3939
|
-
&& waveTick * DEEPSEEK_WAVE_TICK_MS < deepseekWaveDuration(waveTier, waveStyle)
|
|
3940
|
-
useEffect(() => {
|
|
3941
|
-
if (!waveActive) return
|
|
3942
|
-
const id = setInterval(() => {
|
|
3943
|
-
setWaveTick(current => (current === null ? 0 : current + 1))
|
|
3944
|
-
}, DEEPSEEK_WAVE_TICK_MS)
|
|
3945
|
-
return () => {
|
|
3946
|
-
clearInterval(id)
|
|
3947
|
-
}
|
|
3948
|
-
}, [waveActive])
|
|
4355
|
+
// The DeepSeek easter-egg wave renders through the ComposerWave leaf
|
|
4356
|
+
// below, which owns its 33ms tick: the sweep re-renders only that child at
|
|
4357
|
+
// 30fps — this component's editor model, menu, and derived state never
|
|
4358
|
+
// re-run per frame. App drives the tier/style pair on a model switch, and
|
|
4359
|
+
// the child remounts whenever that pair changes (App picks a NEW random
|
|
4360
|
+
// style for every replay, so the pair always differs when a wave should
|
|
4361
|
+
// run), resetting the timeline to frame 0 before the first paint.
|
|
4362
|
+
//
|
|
4363
|
+
// The sweep is strictly one-shot per trigger, and the latch lives HERE —
|
|
4364
|
+
// not in the leaf — because modal panels freeze the composer and UNMOUNT
|
|
4365
|
+
// ComposerWave; a mount-scoped latch would reset on every panel close and
|
|
4366
|
+
// replay a finished sweep. Keying `wavePlayedKey` by the tier:style pair
|
|
4367
|
+
// survives those unmounts: only a NEW trigger (which always changes the
|
|
4368
|
+
// pair) re-arms the sweep. Busy turns, image preparation, /animation
|
|
4369
|
+
// toggles, and panel open/close on an UNCHANGED model+effort pair never
|
|
4370
|
+
// fire it again.
|
|
4371
|
+
const waveKey = waveTier !== null && waveStyle !== null ? `${waveTier}:${waveStyle}` : null
|
|
4372
|
+
const [wavePlayedKey, setWavePlayedKey] = useState<string | null>(null)
|
|
3949
4373
|
useEffect(() => {
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
4374
|
+
// While animations are off, any pending trigger is consumed silently:
|
|
4375
|
+
// re-enabling must never queue or replay a celebration the user opted
|
|
4376
|
+
// out of watching.
|
|
4377
|
+
if (!animations && waveKey !== null && waveKey !== wavePlayedKey) setWavePlayedKey(waveKey)
|
|
4378
|
+
}, [animations, waveKey, wavePlayedKey])
|
|
4379
|
+
const waveArmed = waveKey !== null && waveKey !== wavePlayedKey
|
|
3953
4380
|
|
|
3954
4381
|
// Every exclusive panel keeps the composer as a stable visual anchor, but
|
|
3955
4382
|
// freezes it to one row: no menu, multiline wrap, or animation.
|
|
@@ -3979,6 +4406,17 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3979
4406
|
useEffect(() => {
|
|
3980
4407
|
onEditorRows(editorRowCount)
|
|
3981
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
|
+
)
|
|
3982
4420
|
// The menu's physical rows ride the same one-way report; the cleanup keeps
|
|
3983
4421
|
// the reserve from outliving the menu (unmount or inactive handoff).
|
|
3984
4422
|
useEffect(() => {
|
|
@@ -4014,7 +4452,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
4014
4452
|
))
|
|
4015
4453
|
}
|
|
4016
4454
|
const frozenLine = value === ''
|
|
4017
|
-
? 'type a message'
|
|
4455
|
+
? frozenHint ?? 'type a message'
|
|
4018
4456
|
: verboseLine(value, Math.max(1, columns - 6))
|
|
4019
4457
|
return band(createElement(
|
|
4020
4458
|
Text,
|
|
@@ -4049,7 +4487,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
4049
4487
|
? preparingImages
|
|
4050
4488
|
? createElement(Text, { color: inkColor(getPalette().warn), bold: true }, '… ')
|
|
4051
4489
|
: busy
|
|
4052
|
-
? createElement(BusyChase)
|
|
4490
|
+
? createElement(BusyChase, { animated: animations })
|
|
4053
4491
|
: createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, `${promptGlyph} `)
|
|
4054
4492
|
: ' ',
|
|
4055
4493
|
parts.before,
|
|
@@ -4064,104 +4502,35 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
4064
4502
|
}
|
|
4065
4503
|
const staticEditor = createElement(Box, { flexDirection: 'column' }, ...editorRows)
|
|
4066
4504
|
|
|
4067
|
-
// The wave paints the SAME visible rows and caret site as the static path
|
|
4068
|
-
//
|
|
4069
|
-
//
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
const style = waveStyle!
|
|
4073
|
-
const bandRgb = getPalette().composerBand
|
|
4074
|
-
const visibleRows = editorViewModel.rows.slice(editorWindowStart, editorWindowStart + editorWindowRows)
|
|
4075
|
-
const totalBandRows = visibleRows.length + 2
|
|
4076
|
-
const waveBg = (row: number, column: number): string => {
|
|
4077
|
-
const rgb = deepseekWaveColumnBg(waveTick!, column, bandWidth, waveTier!, style, hues, bandRgb, row, totalBandRows)
|
|
4078
|
-
return rgb === null ? bandBg : inkColor(rgb)
|
|
4079
|
-
}
|
|
4080
|
-
const blankBandRow = (row: number): ReactElement => {
|
|
4081
|
-
const blanks: ComposerCell[] = []
|
|
4082
|
-
for (let column = 0; column < bandWidth; column += 1) {
|
|
4083
|
-
blanks.push({ char: ' ', width: 1, backgroundColor: waveBg(row, column) })
|
|
4084
|
-
}
|
|
4085
|
-
return createElement(Text, { key: `blank-${row}` }, ...waveRowSpans(blanks))
|
|
4086
|
-
}
|
|
4087
|
-
const cellIndexAtColumn = (cells: readonly ComposerCell[], target: number): number | undefined => {
|
|
4088
|
-
let column = 0
|
|
4089
|
-
for (let index = 0; index < cells.length; index += 1) {
|
|
4090
|
-
if (column === target) return index
|
|
4091
|
-
column += cells[index]!.width ?? visibleColumns(cells[index]!.char)
|
|
4092
|
-
if (column > target) return undefined
|
|
4093
|
-
}
|
|
4094
|
-
return undefined
|
|
4095
|
-
}
|
|
4096
|
-
const editorWaveRows = visibleRows.map((row, visibleIndex) => {
|
|
4097
|
-
const sourceIndex = editorWindowStart + visibleIndex
|
|
4098
|
-
const bandRow = visibleIndex + 1
|
|
4099
|
-
const parts = editorRowParts(row, sourceIndex, caret.row, clampedCursor)
|
|
4100
|
-
const placeholder = sourceIndex === 0 && value === '' && !busy
|
|
4101
|
-
const cells: ComposerCell[] = []
|
|
4102
|
-
let usedColumns = 0
|
|
4103
|
-
const push = (char: string, extra: Omit<ComposerCell, 'char' | 'width' | 'backgroundColor'> = {}): void => {
|
|
4104
|
-
const width = visibleColumns(char)
|
|
4105
|
-
cells.push({ char, width, backgroundColor: waveBg(bandRow, usedColumns), ...extra })
|
|
4106
|
-
usedColumns += width
|
|
4107
|
-
}
|
|
4108
|
-
if (sourceIndex === 0) {
|
|
4109
|
-
push(promptGlyph, { color: promptColor, bold: true })
|
|
4110
|
-
push(' ', { color: promptColor })
|
|
4111
|
-
} else {
|
|
4112
|
-
push(' ')
|
|
4113
|
-
push(' ')
|
|
4114
|
-
}
|
|
4115
|
-
for (const span of splitGraphemes(parts.before)) push(span.text)
|
|
4116
|
-
if (parts.hasCaret) push(parts.caret, { inverse: cursorVisible })
|
|
4117
|
-
const tail = placeholder ? COMPOSER_PLACEHOLDER : parts.after
|
|
4118
|
-
for (const span of splitGraphemes(tail)) push(span.text, placeholder ? { dim: true } : {})
|
|
4119
|
-
while (usedColumns < bandWidth) push(' ')
|
|
4120
|
-
|
|
4121
|
-
const middleBandRow = Math.floor(totalBandRows / 2)
|
|
4122
|
-
if (bandRow === middleBandRow && deepseekWaveWordVisible(waveTick!, waveTier!, style)) {
|
|
4123
|
-
const word = waveTier === 'unknown' ? 'Into the Unknown' : 'deepseek'
|
|
4124
|
-
const start = Math.max(2, Math.floor((bandWidth - word.length) / 2))
|
|
4125
|
-
const indices = Array.from({ length: word.length }, (_, at) => cellIndexAtColumn(cells, start + at))
|
|
4126
|
-
if (indices.every(index => index !== undefined && (cells[index]!.char === ' ' || cells[index]!.dim === true))) {
|
|
4127
|
-
for (let at = 0; at < word.length; at += 1) {
|
|
4128
|
-
const cell = cells[indices[at]!]!
|
|
4129
|
-
cell.char = word[at]!
|
|
4130
|
-
cell.width = 1
|
|
4131
|
-
cell.color = inkColor(deepseekWaveWordHue(at, hues))
|
|
4132
|
-
cell.bold = true
|
|
4133
|
-
cell.dim = false
|
|
4134
|
-
}
|
|
4135
|
-
}
|
|
4136
|
-
}
|
|
4137
|
-
if (bandRow === middleBandRow && (waveTier === 'deepseek' || waveTier === 'unknown') && style === 'wave') {
|
|
4138
|
-
const spark = deepseekWaveSpark(waveTick!)
|
|
4139
|
-
const lastIndex = cellIndexAtColumn(cells, bandWidth - 1)
|
|
4140
|
-
if (spark !== null && lastIndex !== undefined && cells[lastIndex]!.char === ' ') {
|
|
4141
|
-
cells[lastIndex]!.char = spark
|
|
4142
|
-
cells[lastIndex]!.color = promptColor
|
|
4143
|
-
cells[lastIndex]!.bold = true
|
|
4144
|
-
cells[lastIndex]!.dim = false
|
|
4145
|
-
}
|
|
4146
|
-
}
|
|
4147
|
-
return createElement(Text, { key: `editor-${sourceIndex}`, wrap: 'truncate-end' }, ...waveRowSpans(cells))
|
|
4148
|
-
})
|
|
4149
|
-
return createElement(
|
|
4150
|
-
Box,
|
|
4151
|
-
{ flexDirection: 'column', width: bandWidth },
|
|
4152
|
-
blankBandRow(0),
|
|
4153
|
-
...editorWaveRows,
|
|
4154
|
-
blankBandRow(totalBandRows - 1),
|
|
4155
|
-
)
|
|
4156
|
-
}
|
|
4157
|
-
|
|
4505
|
+
// The wave paints the SAME visible rows and caret site as the static path
|
|
4506
|
+
// through the ComposerWave leaf (see its comment). The child remounts on
|
|
4507
|
+
// every tier/style change, so its timeline always starts at frame 0, and
|
|
4508
|
+
// its gate cancels — never freezes — the sweep while busy, preparing
|
|
4509
|
+
// images, or animations are off.
|
|
4158
4510
|
return createElement(
|
|
4159
4511
|
Box,
|
|
4160
4512
|
{ flexDirection: 'column' },
|
|
4161
4513
|
menu,
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
:
|
|
4514
|
+
createElement(ComposerWave, {
|
|
4515
|
+
key: waveKey ?? 'static',
|
|
4516
|
+
tier: waveTier ?? 'deepseek',
|
|
4517
|
+
style: waveStyle ?? 'wave',
|
|
4518
|
+
active: waveTier !== null && waveStyle !== null && !busy && !preparingImages && animations && waveArmed,
|
|
4519
|
+
onSettled: () => {
|
|
4520
|
+
if (waveKey !== null) setWavePlayedKey(waveKey)
|
|
4521
|
+
},
|
|
4522
|
+
fallback: band(staticEditor),
|
|
4523
|
+
bandWidth,
|
|
4524
|
+
bandBg,
|
|
4525
|
+
rows: editorViewModel.rows.slice(editorWindowStart, editorWindowStart + editorWindowRows),
|
|
4526
|
+
windowStart: editorWindowStart,
|
|
4527
|
+
caretRow: caret.row,
|
|
4528
|
+
cursor: clampedCursor,
|
|
4529
|
+
caretVisible: cursorVisible,
|
|
4530
|
+
value,
|
|
4531
|
+
promptGlyph,
|
|
4532
|
+
promptColor,
|
|
4533
|
+
}),
|
|
4165
4534
|
)
|
|
4166
4535
|
}
|
|
4167
4536
|
|
|
@@ -4431,12 +4800,21 @@ export function App(props: AppProps): ReactElement {
|
|
|
4431
4800
|
* to static while the prompt marker keeps the tier accent. The trigger
|
|
4432
4801
|
* follows the applied model label (what the status bar actually shows),
|
|
4433
4802
|
* never the initial paint, and the tier is derived from the label and
|
|
4434
|
-
* cached at the switch. The 33ms tick itself lives inside
|
|
4435
|
-
* sweep re-renders only the composer band, not the whole tree,
|
|
4436
|
-
* App owns the rarely-changing tier/style and
|
|
4437
|
-
*
|
|
4803
|
+
* cached at the switch. The 33ms tick itself lives inside the ComposerWave
|
|
4804
|
+
* leaf, so the sweep re-renders only the composer band, not the whole tree,
|
|
4805
|
+
* at 30fps; App owns the rarely-changing tier/style and the leaf plays the
|
|
4806
|
+
* sweep exactly ONCE per pair change — an unchanged model+effort pair
|
|
4807
|
+
* (ordinary turns, image preparation, /animation toggles) never replays. */
|
|
4438
4808
|
const [waveTier, setWaveTier] = useState<DeepseekWaveTier | null>(null)
|
|
4439
4809
|
const [waveStyle, setWaveStyle] = useState<DeepseekWaveStyle | null>(null)
|
|
4810
|
+
// /animation toggle: applies immediately, persists through the runner, and
|
|
4811
|
+
// gates every timed leaf (shimmer, chase, blink, wave) for this render.
|
|
4812
|
+
const [animations, setAnimations] = useState(props.animations ?? true)
|
|
4813
|
+
const applyAnimations = (enabled: boolean): void => {
|
|
4814
|
+
setAnimations(enabled)
|
|
4815
|
+
props.saveAnimations?.(enabled)
|
|
4816
|
+
notify(`animations ${enabled ? 'on' : 'off'}`)
|
|
4817
|
+
}
|
|
4440
4818
|
const previousModel = useRef<string | undefined>(undefined)
|
|
4441
4819
|
const previousEffort = useRef<string | undefined>(props.effort)
|
|
4442
4820
|
const previousStyle = useRef<DeepseekWaveStyle | undefined>(undefined)
|
|
@@ -4557,6 +4935,8 @@ export function App(props: AppProps): ReactElement {
|
|
|
4557
4935
|
const [resumeOpen, setResumeOpen] = useState(false)
|
|
4558
4936
|
const [pluginOpen, setPluginOpen] = useState(false)
|
|
4559
4937
|
const [pluginQuery, setPluginQuery] = useState('')
|
|
4938
|
+
const [updateOpen, setUpdateOpen] = useState(false)
|
|
4939
|
+
const [scheduleOpen, setScheduleOpen] = useState(false)
|
|
4560
4940
|
const [jobsOpen, setJobsOpen] = useState(false)
|
|
4561
4941
|
const [statuslineOpen, setStatuslineOpen] = useState(false)
|
|
4562
4942
|
const [statuslineItems, setStatuslineItems] = useState<readonly StatusItemId[]>(() => parseStatuslineItems(props.statusline))
|
|
@@ -4636,7 +5016,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
4636
5016
|
// panel keypress.
|
|
4637
5017
|
const inputActive = deleteConfirmId !== undefined
|
|
4638
5018
|
? !approvalPending && !questionPending
|
|
4639
|
-
: !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
|
|
4640
5020
|
|
|
4641
5021
|
// Human questions outrank local inspectors. Close the lower modal instead
|
|
4642
5022
|
// of leaving an approval/question visible but keyboard-locked behind it.
|
|
@@ -4651,6 +5031,8 @@ export function App(props: AppProps): ReactElement {
|
|
|
4651
5031
|
setPermissionOpen(false)
|
|
4652
5032
|
setResumeOpen(false)
|
|
4653
5033
|
setPluginOpen(false)
|
|
5034
|
+
setUpdateOpen(false)
|
|
5035
|
+
setScheduleOpen(false)
|
|
4654
5036
|
setStatuslineOpen(false)
|
|
4655
5037
|
setThemeOpen(false)
|
|
4656
5038
|
setHistoryOpen(false)
|
|
@@ -4668,7 +5050,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
4668
5050
|
// contract that lets arbitrarily long conversations scroll instead of
|
|
4669
5051
|
// freezing when the live tree exceeds the terminal height. The dynamic
|
|
4670
5052
|
// region below stays small: the streaming tail, modals, composer, and its
|
|
4671
|
-
// status footer.
|
|
5053
|
+
// status footer. Live stream frames preserve `entries` identity.
|
|
4672
5054
|
//
|
|
4673
5055
|
// `computeSettledRows` extends the cached row set incrementally: the
|
|
4674
5056
|
// settled prefix is permanently final, so a grown boundary builds ONLY the
|
|
@@ -4753,6 +5135,16 @@ export function App(props: AppProps): ReactElement {
|
|
|
4753
5135
|
const handleMenuRows = useCallback((rows: number): void => {
|
|
4754
5136
|
setMenuRows(current => (current === rows ? current : rows))
|
|
4755
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
|
|
4756
5148
|
const composerEditorCap = composerMaxRows(terminalRows)
|
|
4757
5149
|
// Bottom chrome is composer (2 borders + composerRows) + status (up to 2
|
|
4758
5150
|
// rows) + todo/agents/notice (3) = 8 resting rows, plus the historical
|
|
@@ -4764,6 +5156,10 @@ export function App(props: AppProps): ReactElement {
|
|
|
4764
5156
|
const dynamicRows = Math.max(1, terminalRows - 8 - MENU_RESERVE_ROWS - composerGutterRows - (composerRows - 1) - Math.max(0, menuRows - MENU_RESERVE_ROWS))
|
|
4765
5157
|
const streamingActive = view.streaming !== '' || view.streamingReasoning !== ''
|
|
4766
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)
|
|
4767
5163
|
const allLiveLines = useMemo(
|
|
4768
5164
|
() => view.entries.slice(settled).flatMap(
|
|
4769
5165
|
// Width shrinks with the real terminal (no 10-column floor: on a
|
|
@@ -4812,9 +5208,55 @@ export function App(props: AppProps): ReactElement {
|
|
|
4812
5208
|
: visibleLiveLines.slice(-liveAudit.allocation.live)
|
|
4813
5209
|
const auditedReasoningRows = liveAudit.allocation.reasoning
|
|
4814
5210
|
const auditedAnswerRows = liveAudit.allocation.answer
|
|
4815
|
-
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
|
|
4816
5212
|
const inspectorVisible = verboseOpen && !approvalPending && !questionPending
|
|
4817
|
-
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'}`
|
|
4818
5260
|
const closeInspector = useCallback((): void => {
|
|
4819
5261
|
setVerboseOpen(false)
|
|
4820
5262
|
}, [])
|
|
@@ -5114,7 +5556,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
5114
5556
|
// marker falls back to the static dim row — same as Deep diving
|
|
5115
5557
|
// always yields the live region to streaming content.
|
|
5116
5558
|
: view.streaming === ''
|
|
5117
|
-
? createElement(ShimmerLine, { text: '✻ Thinking… (Ctrl/Alt+R to expand)' })
|
|
5559
|
+
? createElement(ShimmerLine, { text: '✻ Thinking… (Ctrl/Alt+R to expand)', animated: animations })
|
|
5118
5560
|
: createElement(StreamTail, {
|
|
5119
5561
|
text: 'Thinking… (Ctrl/Alt+R to expand)',
|
|
5120
5562
|
prefix: '✻ ',
|
|
@@ -5129,10 +5571,10 @@ export function App(props: AppProps): ReactElement {
|
|
|
5129
5571
|
// The same two-column gutter as settled replies: streamed text
|
|
5130
5572
|
// lands exactly where the assembled message will render.
|
|
5131
5573
|
{ text: view.streaming, dim: false, maxRows: auditedAnswerRows, prefix: ' ' },
|
|
5132
|
-
busy ? createElement(Caret) : undefined,
|
|
5574
|
+
busy ? createElement(Caret, { animated: animations }) : undefined,
|
|
5133
5575
|
)
|
|
5134
5576
|
: undefined,
|
|
5135
|
-
deepDivingVisible ? createElement(DeepDivingLine, { since: view.busySince }) : undefined,
|
|
5577
|
+
deepDivingVisible ? createElement(DeepDivingLine, { since: view.busySince, animated: animations }) : undefined,
|
|
5136
5578
|
)
|
|
5137
5579
|
: undefined,
|
|
5138
5580
|
transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : undefined,
|
|
@@ -5216,6 +5658,17 @@ export function App(props: AppProps): ReactElement {
|
|
|
5216
5658
|
pluginOpen && !approvalPending && !questionPending
|
|
5217
5659
|
? createElement(PluginPanel, { load: props.loadPlugins, initialQuery: pluginQuery, close: () => setPluginOpen(false) })
|
|
5218
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,
|
|
5219
5672
|
jobsOpen && !approvalPending && !questionPending
|
|
5220
5673
|
? createElement(JobsPanel, { load: props.loadJobs, close: () => setJobsOpen(false) })
|
|
5221
5674
|
: undefined,
|
|
@@ -5301,6 +5754,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
5301
5754
|
createElement(Input, {
|
|
5302
5755
|
active: inputActive,
|
|
5303
5756
|
frozen: modalVisible,
|
|
5757
|
+
frozenHint,
|
|
5304
5758
|
busy,
|
|
5305
5759
|
descriptors,
|
|
5306
5760
|
skills,
|
|
@@ -5368,6 +5822,8 @@ export function App(props: AppProps): ReactElement {
|
|
|
5368
5822
|
openPermission: () => setPermissionOpen(true),
|
|
5369
5823
|
openResume: () => { setResumeDelete({ mode: false }); setResumeOpen(true) },
|
|
5370
5824
|
openPlugin: (query = '') => { setPluginQuery(query); setPluginOpen(true) },
|
|
5825
|
+
openUpdate: () => setUpdateOpen(true),
|
|
5826
|
+
openSchedule: () => setScheduleOpen(true),
|
|
5371
5827
|
openJobs: () => setJobsOpen(true),
|
|
5372
5828
|
openStatusline: () => setStatuslineOpen(true),
|
|
5373
5829
|
openTheme: () => setThemeOpen(true),
|
|
@@ -5419,7 +5875,10 @@ export function App(props: AppProps): ReactElement {
|
|
|
5419
5875
|
loadMentions: props.loadMentions,
|
|
5420
5876
|
inspectImages: props.inspectImages,
|
|
5421
5877
|
prepareImages: props.prepareImages,
|
|
5422
|
-
|
|
5878
|
+
inspectFiles: props.inspectFiles,
|
|
5879
|
+
prepareFiles: props.prepareFiles,
|
|
5880
|
+
sessionKey: props.sessionKey,
|
|
5881
|
+
cycleMode: props.cycleMode,
|
|
5423
5882
|
exportTranscript: props.exportTranscript,
|
|
5424
5883
|
renameTitle: props.renameTitle,
|
|
5425
5884
|
copyLastResponse: props.copyLastResponse,
|
|
@@ -5430,9 +5889,13 @@ export function App(props: AppProps): ReactElement {
|
|
|
5430
5889
|
cancelQueued: props.cancelQueued,
|
|
5431
5890
|
historyFill,
|
|
5432
5891
|
historyConsumed,
|
|
5892
|
+
animations,
|
|
5893
|
+
applyAnimations,
|
|
5433
5894
|
waveTier,
|
|
5434
5895
|
waveStyle,
|
|
5435
5896
|
maxRows: composerEditorCap,
|
|
5897
|
+
anchorRowsBelow: imeRowsBelowComposer,
|
|
5898
|
+
tabTitle,
|
|
5436
5899
|
onEditorRows: handleEditorRows,
|
|
5437
5900
|
onMenuRows: handleMenuRows,
|
|
5438
5901
|
}),
|
|
@@ -5444,7 +5907,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
5444
5907
|
branch: props.branch,
|
|
5445
5908
|
sessionId: props.sessionId,
|
|
5446
5909
|
title: view.title,
|
|
5447
|
-
plan: view.plan,
|
|
5910
|
+
plan: view.plan || props.pendingPlan === true,
|
|
5448
5911
|
permission: view.permission !== '' ? view.permission : props.permission,
|
|
5449
5912
|
sandbox: view.sandbox,
|
|
5450
5913
|
goal: view.goal === undefined ? undefined : { phase: view.goal.phase, rounds: view.goal.rounds, max: view.goal.max },
|
|
@@ -5453,6 +5916,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
5453
5916
|
busy,
|
|
5454
5917
|
columns: terminalColumns,
|
|
5455
5918
|
items: statuslineItems,
|
|
5919
|
+
onRows: handleStatusRows,
|
|
5456
5920
|
}),
|
|
5457
5921
|
),
|
|
5458
5922
|
)
|