dsh-code 1.0.4 → 1.0.6
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 +287 -286
- package/README.md +16 -13
- package/bin/deepseek.mjs +336 -11
- package/cordis.patch.yml +26 -18
- package/lib/index.mjs +1310 -439
- package/lib/types/app.d.ts +25 -6
- package/lib/types/attachments.d.ts +36 -4
- package/lib/types/git-workflow.d.ts +7 -2
- package/lib/types/history.d.ts +18 -11
- package/lib/types/index.d.ts +11 -2
- package/lib/types/presets.d.ts +4 -1
- package/lib/types/provider-settings.d.ts +6 -11
- package/lib/types/questions.d.ts +16 -12
- package/lib/types/render/animations.d.ts +74 -7
- package/lib/types/render/export.d.ts +0 -6
- package/lib/types/render/fuzzy.d.ts +21 -0
- package/lib/types/render/projection.d.ts +47 -5
- package/lib/types/session-directory.d.ts +48 -13
- package/lib/types/settings-file.d.ts +8 -0
- package/lib/types/store.d.ts +3 -0
- package/package.json +168 -159
- package/src/app.ts +480 -199
- package/src/attachments.ts +110 -11
- package/src/commands.ts +35 -5
- package/src/git-workflow.ts +29 -10
- package/src/history.ts +22 -13
- package/src/index.ts +1868 -1752
- package/src/internals.ts +61 -40
- package/src/permissions.ts +1 -1
- package/src/presets.ts +19 -6
- package/src/provider-settings.ts +12 -12
- package/src/questions.ts +57 -74
- package/src/render/animations.ts +606 -403
- package/src/render/export.ts +20 -10
- package/src/render/fuzzy.ts +83 -0
- package/src/render/projection.ts +1833 -1620
- package/src/session-directory.ts +94 -16
- package/src/settings-file.ts +38 -6
- package/src/skills.ts +23 -9
- package/src/store.ts +39 -1
- package/src/subagents.ts +26 -3
package/src/app.ts
CHANGED
|
@@ -20,8 +20,8 @@ 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'
|
|
24
|
-
import type { TodoItem } from '@deepseek-ai/dsh-
|
|
23
|
+
import type { ContentBlock, FileBlock, ImageBlock } from '@deepseek-ai/dsh-llm'
|
|
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'
|
|
27
27
|
import {
|
|
@@ -42,6 +42,7 @@ import { type MdSegment, visibleColumns } from './render/markdown.ts'
|
|
|
42
42
|
import {
|
|
43
43
|
busyChaseFrame,
|
|
44
44
|
BUSY_CHASE_TICK_MS,
|
|
45
|
+
CARET_BLINK_TICK_MS,
|
|
45
46
|
caretVisible,
|
|
46
47
|
DEEP_DIVING_SHIMMER_TICK_MS,
|
|
47
48
|
DEEPSEEK_WAVE_TICK_MS,
|
|
@@ -56,11 +57,13 @@ import {
|
|
|
56
57
|
deepDivingSparkColor,
|
|
57
58
|
effortAboveHigh,
|
|
58
59
|
isOfficialDeepSeekLabel,
|
|
60
|
+
parseAnimationsArgument,
|
|
59
61
|
type DeepseekWaveStyle,
|
|
60
62
|
type DeepseekWaveTier,
|
|
61
63
|
} from './render/animations.ts'
|
|
62
64
|
import type { ApprovalSnapshot, ApprovalStore } from './approval.ts'
|
|
63
|
-
import { submissionPayload, type CommandsView } from './commands.ts'
|
|
65
|
+
import { isSlashLine, submissionPayload, type CommandsView } from './commands.ts'
|
|
66
|
+
import { rankByName } from './render/fuzzy.ts'
|
|
64
67
|
import type { ModelDirectory, ModelRow } from './models.ts'
|
|
65
68
|
import {
|
|
66
69
|
isDeclaredReasoningEfforts,
|
|
@@ -100,7 +103,8 @@ import {
|
|
|
100
103
|
import { ProviderAuthorizationLogoutPanel, ProviderAuthorizationPanel } from './authorization-panel.ts'
|
|
101
104
|
import {
|
|
102
105
|
looksLikeImagePath,
|
|
103
|
-
|
|
106
|
+
parsePastedAttachmentPaths,
|
|
107
|
+
type FilePathInspection,
|
|
104
108
|
type ImagePathInspection,
|
|
105
109
|
} from './attachments.ts'
|
|
106
110
|
|
|
@@ -196,6 +200,7 @@ import {
|
|
|
196
200
|
editorRowParts,
|
|
197
201
|
insertText,
|
|
198
202
|
type EditResult,
|
|
203
|
+
type EditorRowModel,
|
|
199
204
|
killToLineEnd,
|
|
200
205
|
killToLineStart,
|
|
201
206
|
moveCursorBy,
|
|
@@ -228,6 +233,7 @@ const LOCAL_COMMANDS = [
|
|
|
228
233
|
{ label: '/jobs', description: 'inspect background jobs' },
|
|
229
234
|
{ label: '/statusline', description: 'customize the status line items' },
|
|
230
235
|
{ label: '/theme', description: 'switch the color theme' },
|
|
236
|
+
{ label: '/animation', description: 'toggle timed animations (/animation [on|off])' },
|
|
231
237
|
{ label: '/history', description: 'search and recall past prompts' },
|
|
232
238
|
{ label: '/agents', description: 'inspect subagent sessions of this conversation' },
|
|
233
239
|
{ label: '/todos', description: 'inspect the full todo list' },
|
|
@@ -277,10 +283,20 @@ export interface AppProps {
|
|
|
277
283
|
mode: string
|
|
278
284
|
/** Permission preset selected for the current or pending first session. */
|
|
279
285
|
permission: string
|
|
280
|
-
/**
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
286
|
+
/**
|
|
287
|
+
* Submit one line: slash commands to the registry, other text to the agent.
|
|
288
|
+
* The optional origin names the session the submission was composed for —
|
|
289
|
+
* an attachment prepare resolves after the app remounted onto another
|
|
290
|
+
* session, and the runner drops the stale delivery then.
|
|
291
|
+
*/
|
|
292
|
+
dispatch(text: string, attachments?: readonly ContentBlock[], origin?: string): void
|
|
293
|
+
/** Submit steering, with the same stale-delivery guard as {@link dispatch}. */
|
|
294
|
+
steer(text: string, attachments?: readonly ContentBlock[], origin?: string): void
|
|
295
|
+
/**
|
|
296
|
+
* The FULL current session identity ('' while the first session is pending)
|
|
297
|
+
* — the stale-delivery origin above. Distinct from the short display id.
|
|
298
|
+
*/
|
|
299
|
+
sessionKey: string
|
|
284
300
|
/** Interrupt the running turn (Esc); true when a turn was cancelled. */
|
|
285
301
|
interrupt(): boolean
|
|
286
302
|
/** Quit: unmount, flush, and request process exit. */
|
|
@@ -293,6 +309,10 @@ export interface AppProps {
|
|
|
293
309
|
inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
|
|
294
310
|
/** Validate, normalize and persist images immediately before submission. */
|
|
295
311
|
prepareImages(paths: readonly string[], signal?: AbortSignal): Promise<readonly ImageBlock[]>
|
|
312
|
+
/** Validate draft non-image file paths without committing attachment objects. */
|
|
313
|
+
inspectFiles(paths: readonly string[]): Promise<readonly FilePathInspection[]>
|
|
314
|
+
/** Persist non-image files immediately before submission as durable file blocks. */
|
|
315
|
+
prepareFiles(paths: readonly string[], signal?: AbortSignal): Promise<readonly FileBlock[]>
|
|
296
316
|
/** Apply one /model selection (with an advertised reasoning effort, when picked); returns the display label. */
|
|
297
317
|
selectModel(row: ModelRow, effortId?: string): string
|
|
298
318
|
/** The /subagent override label, '' when delegated agents follow the current model. */
|
|
@@ -377,6 +397,11 @@ export interface AppProps {
|
|
|
377
397
|
saveStatusline(items: readonly string[]): void
|
|
378
398
|
/** Apply and persist one /theme selection; the runner owns the theme.json file. */
|
|
379
399
|
saveTheme?(name: ThemeName): void
|
|
400
|
+
/** Whether timed animations run at startup (animations.json; on by default
|
|
401
|
+
* — like parseAnimationsPref, only an explicit false disables them). */
|
|
402
|
+
animations?: boolean
|
|
403
|
+
/** Apply and persist one /animation toggle; the runner owns the file. */
|
|
404
|
+
saveAnimations?(enabled: boolean): void
|
|
380
405
|
/** Persistent cross-session input history (oldest first); the runner owns the file. */
|
|
381
406
|
history: readonly string[]
|
|
382
407
|
/** Persist one submitted prompt to the global history file. */
|
|
@@ -393,12 +418,23 @@ function padColumns(text: string, width: number): string {
|
|
|
393
418
|
return clipped + ' '.repeat(Math.max(0, width - visibleColumns(clipped)))
|
|
394
419
|
}
|
|
395
420
|
|
|
396
|
-
/**
|
|
421
|
+
/**
|
|
422
|
+
* Wall-clock frame counter for one self-contained animated leaf. Each fire
|
|
423
|
+
* derives the tick from elapsed time instead of counting intervals, so a
|
|
424
|
+
* stretched interval (busy event loop, slow SSH) skips the animation ahead
|
|
425
|
+
* rather than slowing it down; the tick always tracks real time.
|
|
426
|
+
*/
|
|
397
427
|
function useFrames(intervalMs: number, active = true): number {
|
|
398
428
|
const [tick, setTick] = useState(0)
|
|
399
429
|
useEffect(() => {
|
|
400
430
|
if (!active) return
|
|
401
|
-
const
|
|
431
|
+
const startedAt = Date.now()
|
|
432
|
+
setTick(0)
|
|
433
|
+
const id = setInterval(() => {
|
|
434
|
+
// Clock setback (NTP resync) must not produce negative ticks — the
|
|
435
|
+
// blink parity check would flip the caret off for a full period.
|
|
436
|
+
setTick(Math.max(0, Math.floor((Date.now() - startedAt) / intervalMs)))
|
|
437
|
+
}, intervalMs)
|
|
402
438
|
return () => {
|
|
403
439
|
clearInterval(id)
|
|
404
440
|
}
|
|
@@ -421,15 +457,18 @@ function useStableInput(handler: (input: string, key: Key) => void, active: bool
|
|
|
421
457
|
useInput(stableHandler, { isActive: active })
|
|
422
458
|
}
|
|
423
459
|
|
|
424
|
-
/**
|
|
425
|
-
|
|
426
|
-
|
|
460
|
+
/**
|
|
461
|
+
* The original web StateDot chase used by the busy composer marker. With
|
|
462
|
+
* animations off it freezes on the first frame (still visibly busy).
|
|
463
|
+
*/
|
|
464
|
+
function BusyChase({ animated = true }: { animated?: boolean }): ReactElement {
|
|
465
|
+
const tick = useFrames(BUSY_CHASE_TICK_MS, animated)
|
|
427
466
|
return createElement(Text, { color: inkColor(getPalette().brandBright) }, busyChaseFrame(tick) + ' ')
|
|
428
467
|
}
|
|
429
468
|
|
|
430
|
-
/** Blinking block caret appended to streaming text. */
|
|
431
|
-
function Caret(): ReactElement {
|
|
432
|
-
const tick = useFrames(
|
|
469
|
+
/** Blinking block caret appended to streaming text; solid when frozen. */
|
|
470
|
+
function Caret({ animated = true }: { animated?: boolean }): ReactElement {
|
|
471
|
+
const tick = useFrames(CARET_BLINK_TICK_MS, animated)
|
|
433
472
|
return createElement(Text, null, caretVisible(tick) ? '▍' : ' ')
|
|
434
473
|
}
|
|
435
474
|
|
|
@@ -440,7 +479,7 @@ function useCursorBlink(active: boolean): { visible: boolean; reset(): void } {
|
|
|
440
479
|
useEffect(() => {
|
|
441
480
|
setVisible(true)
|
|
442
481
|
if (!active) return
|
|
443
|
-
const id = setInterval(() => setVisible(current => !current),
|
|
482
|
+
const id = setInterval(() => setVisible(current => !current), CARET_BLINK_TICK_MS)
|
|
444
483
|
return () => {
|
|
445
484
|
clearInterval(id)
|
|
446
485
|
}
|
|
@@ -456,10 +495,12 @@ function useCursorBlink(active: boolean): { visible: boolean; reset(): void } {
|
|
|
456
495
|
* One bounded line painted with the deep-diving shimmer: a continuously
|
|
457
496
|
* moving blue gradient across graphemes, the `✻` glyph in the breathing
|
|
458
497
|
* 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.
|
|
498
|
+
* always exactly one row (truncate-end) so the live budget stays exact. With
|
|
499
|
+
* animations off the same spans render in fixed colors — no timer, no
|
|
500
|
+
* per-frame repaint, the `✻` keeps its highlight.
|
|
460
501
|
*/
|
|
461
|
-
function ShimmerLine({ text }: { text: string }): ReactElement {
|
|
462
|
-
const tick = useFrames(DEEP_DIVING_SHIMMER_TICK_MS)
|
|
502
|
+
function ShimmerLine({ text, animated = true }: { text: string; animated?: boolean }): ReactElement {
|
|
503
|
+
const tick = useFrames(DEEP_DIVING_SHIMMER_TICK_MS, animated)
|
|
463
504
|
const palette = getPalette()
|
|
464
505
|
const graphemes = splitGraphemes(text)
|
|
465
506
|
return createElement(
|
|
@@ -471,7 +512,11 @@ function ShimmerLine({ text }: { text: string }): ReactElement {
|
|
|
471
512
|
Text,
|
|
472
513
|
{
|
|
473
514
|
key: `${grapheme.start}-${grapheme.end}`,
|
|
474
|
-
color: inkColor(
|
|
515
|
+
color: inkColor(!animated
|
|
516
|
+
? (sparkle ? palette.brandBright : palette.brandDeep)
|
|
517
|
+
: sparkle
|
|
518
|
+
? deepDivingSparkColor(tick, palette.brandDeep, palette.brandBright)
|
|
519
|
+
: deepDivingGradientColor(index, tick, graphemes.length, palette.brandDeep, palette.brandBright)),
|
|
475
520
|
bold: sparkle || undefined,
|
|
476
521
|
},
|
|
477
522
|
grapheme.text,
|
|
@@ -486,10 +531,10 @@ function ShimmerLine({ text }: { text: string }): ReactElement {
|
|
|
486
531
|
* only once the turn has clearly been running (15s) — anchored to `turn/start`
|
|
487
532
|
* so a resumed mid-turn keeps the real time.
|
|
488
533
|
*/
|
|
489
|
-
function DeepDivingLine({ since }: { since: number }): ReactElement {
|
|
534
|
+
function DeepDivingLine({ since, animated = true }: { since: number; animated?: boolean }): ReactElement {
|
|
490
535
|
const elapsed = since === 0 ? 0 : Date.now() - since
|
|
491
536
|
const text = elapsed >= 15_000 ? `✻ Deep diving... ${runClock(elapsed)}` : '✻ Deep diving...'
|
|
492
|
-
return createElement(ShimmerLine, { text })
|
|
537
|
+
return createElement(ShimmerLine, { text, animated })
|
|
493
538
|
}
|
|
494
539
|
|
|
495
540
|
/**
|
|
@@ -1908,7 +1953,10 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
|
|
|
1908
1953
|
const manualKeyConfigured = row.credential?.kind === 'facts' && row.credential.configured
|
|
1909
1954
|
const showAuthorization = !manualKeyConfigured || authorization?.record.configured === true || authorization?.inFlight === true
|
|
1910
1955
|
const authLabel = showAuthorization ? ' · ' + providerAuthorizationStatus(authorization) : ''
|
|
1911
|
-
|
|
1956
|
+
// The adapter's configuration diagnostic rides the row (the provider
|
|
1957
|
+
// stays listed and repairable — this is why it did not vanish).
|
|
1958
|
+
const diagnostic = row.diagnostic === undefined ? '' : ' · ! ' + singleLineText(row.diagnostic)
|
|
1959
|
+
const label = identity + ' · ' + providerStateLabel(row) + authLabel + (row.removable ? ' · custom' : '') + diagnostic
|
|
1912
1960
|
// Configured rows render in the intermediate brand blue so the in-use
|
|
1913
1961
|
// group reads at a glance; the dormant tail keeps the dim caption gray.
|
|
1914
1962
|
const idleColor = row.configured ? inkColor(getPalette().brandMid) : inkColor(getPalette().dim)
|
|
@@ -2249,7 +2297,9 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
|
|
|
2249
2297
|
const keyBullets = '•'.repeat(Math.min([...keyDraft].length, Math.max(1, viewport.contentColumns - 14)))
|
|
2250
2298
|
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
2299
|
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
|
-
|
|
2300
|
+
// The fixed diagnostic row (present only with an adapter error) joins the
|
|
2301
|
+
// same height budget as the state rows — it must never overflow the panel.
|
|
2302
|
+
const rowBudget = Math.max(0, viewport.bodyRows - stateRows.length - (target.diagnostic === undefined ? 0 : 1) - 3)
|
|
2253
2303
|
const first = selectionWindow(cursor, models.length + 1, rowBudget)
|
|
2254
2304
|
const modelRows: ReactElement[] = []
|
|
2255
2305
|
for (let index = first; index < first + Math.max(0, Math.min(models.length + 1 - first, rowBudget)); index += 1) {
|
|
@@ -2271,6 +2321,13 @@ function ProviderSetupPanel({ target, save, saveCredential, discover, effortDono
|
|
|
2271
2321
|
Box,
|
|
2272
2322
|
{ flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
|
|
2273
2323
|
createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns('/model — configure ' + target.displayName, viewport.contentColumns)),
|
|
2324
|
+
// The adapter's configuration diagnostic heads the editor: the provider
|
|
2325
|
+
// is here precisely because it stayed listed for repair.
|
|
2326
|
+
...(target.diagnostic === undefined ? [] : [createElement(
|
|
2327
|
+
Text,
|
|
2328
|
+
{ key: 'diagnostic', color: inkColor(getPalette().warn), wrap: 'truncate-end' },
|
|
2329
|
+
truncateColumns('! ' + displayText(singleLineText(target.diagnostic)), viewport.contentColumns),
|
|
2330
|
+
)]),
|
|
2274
2331
|
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
2275
2332
|
keyRow,
|
|
2276
2333
|
urlRow,
|
|
@@ -2610,6 +2667,197 @@ function waveRowSpans(cells: readonly ComposerCell[]): ReactElement[] {
|
|
|
2610
2667
|
return spans
|
|
2611
2668
|
}
|
|
2612
2669
|
|
|
2670
|
+
/** Index of the cell STARTING at a display column, if one does. */
|
|
2671
|
+
function cellIndexAtColumn(cells: readonly ComposerCell[], target: number): number | undefined {
|
|
2672
|
+
let column = 0
|
|
2673
|
+
for (let index = 0; index < cells.length; index += 1) {
|
|
2674
|
+
if (column === target) return index
|
|
2675
|
+
column += cells[index]!.width ?? visibleColumns(cells[index]!.char)
|
|
2676
|
+
if (column > target) return undefined
|
|
2677
|
+
}
|
|
2678
|
+
return undefined
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
/**
|
|
2682
|
+
* Wall-clock wave frames — strictly ONE sweep per MOUNT; the mount-spanning
|
|
2683
|
+
* one-shot latch (surviving modal unmounts) lives in Input as `wavePlayedKey`.
|
|
2684
|
+
* The first gate-off after the sweep has started (it completed, a turn went
|
|
2685
|
+
* busy, image preparation began, animations were toggled off) latches `done`
|
|
2686
|
+
* for this mount, so the same mount can never resume or replay. A trigger
|
|
2687
|
+
* that lands while the gate is already down stays pending until the gate
|
|
2688
|
+
* rises once, then plays.
|
|
2689
|
+
*/
|
|
2690
|
+
function useWaveFrames(active: boolean, durationMs: number): { tick: number; done: boolean } {
|
|
2691
|
+
const [tick, setTick] = useState(0)
|
|
2692
|
+
const [done, setDone] = useState(false)
|
|
2693
|
+
const startedRef = useRef(false)
|
|
2694
|
+
useEffect(() => {
|
|
2695
|
+
if (done) return
|
|
2696
|
+
if (!active) {
|
|
2697
|
+
// A sweep that already started is cancelled permanently, never resumed.
|
|
2698
|
+
if (startedRef.current) setDone(true)
|
|
2699
|
+
return
|
|
2700
|
+
}
|
|
2701
|
+
startedRef.current = true
|
|
2702
|
+
const startedAt = Date.now()
|
|
2703
|
+
const id = setInterval(() => {
|
|
2704
|
+
const elapsed = Date.now() - startedAt
|
|
2705
|
+
if (elapsed >= durationMs) {
|
|
2706
|
+
clearInterval(id)
|
|
2707
|
+
setDone(true)
|
|
2708
|
+
return
|
|
2709
|
+
}
|
|
2710
|
+
setTick(Math.max(0, Math.floor(elapsed / DEEPSEEK_WAVE_TICK_MS)))
|
|
2711
|
+
}, DEEPSEEK_WAVE_TICK_MS)
|
|
2712
|
+
return () => {
|
|
2713
|
+
clearInterval(id)
|
|
2714
|
+
}
|
|
2715
|
+
}, [active, durationMs, done])
|
|
2716
|
+
return { tick, done }
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
/** The wave-painted composer band: everything the sweep needs, as data. */
|
|
2720
|
+
interface ComposerWaveProps {
|
|
2721
|
+
/** Wave tier of the applied route (flash / deepseek / unknown). */
|
|
2722
|
+
tier: DeepseekWaveTier
|
|
2723
|
+
/** Ignition style App picked for this trigger. */
|
|
2724
|
+
style: DeepseekWaveStyle
|
|
2725
|
+
/** False while busy, preparing images, or animations are off; the fallback
|
|
2726
|
+
* band renders instead (non-wave routes keep it false permanently). */
|
|
2727
|
+
active: boolean
|
|
2728
|
+
/** The static band to render before, after, and instead of the sweep. */
|
|
2729
|
+
fallback: ReactElement
|
|
2730
|
+
/** Composer band width in columns (terminal width minus the last column). */
|
|
2731
|
+
bandWidth: number
|
|
2732
|
+
/** Ink color of the static band background (the transparent-cell base). */
|
|
2733
|
+
bandBg: string
|
|
2734
|
+
/** The editor's visible physical rows (already windowed). */
|
|
2735
|
+
rows: readonly EditorRowModel[]
|
|
2736
|
+
/** Index of `rows[0]` in the full editor model (keying + caret row math). */
|
|
2737
|
+
windowStart: number
|
|
2738
|
+
/** Absolute caret row in the editor model. */
|
|
2739
|
+
caretRow: number
|
|
2740
|
+
/** The authoritative cursor offset. */
|
|
2741
|
+
cursor: number
|
|
2742
|
+
/** Caret blink visibility (shared with the static path). */
|
|
2743
|
+
caretVisible: boolean
|
|
2744
|
+
/** The draft text (placeholder detection on row 0). */
|
|
2745
|
+
value: string
|
|
2746
|
+
/** Tier prompt glyph and accent color (persistent, like Codex's charge). */
|
|
2747
|
+
promptGlyph: string
|
|
2748
|
+
promptColor: string
|
|
2749
|
+
/** Fires EXACTLY ONCE when this sweep ends for any reason — completed,
|
|
2750
|
+
* cancelled by the gate, or unmounted (a modal panel froze the composer) —
|
|
2751
|
+
* so Input's played-key latch survives the leaf's unmount/remount cycle. */
|
|
2752
|
+
onSettled(): void
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
/**
|
|
2756
|
+
* The self-contained wave leaf: it owns its 33ms tick, so the sweep
|
|
2757
|
+
* re-renders ONLY this component at ~30fps — Input's derived editor state
|
|
2758
|
+
* never re-runs per frame. Graphemes stay atomic and every background sample
|
|
2759
|
+
* advances by terminal display columns, so CJK and emoji cannot move the
|
|
2760
|
+
* caret or wrap the band. The duration gate renders the fallback band on the
|
|
2761
|
+
* frame the sweep completes.
|
|
2762
|
+
*/
|
|
2763
|
+
function ComposerWave(props: ComposerWaveProps): ReactElement {
|
|
2764
|
+
const { tier, style } = props
|
|
2765
|
+
const durationMs = deepseekWaveDuration(tier, style)
|
|
2766
|
+
const { tick, done } = useWaveFrames(props.active, durationMs)
|
|
2767
|
+
// Report the sweep's end exactly once — completion, gate cancellation, or
|
|
2768
|
+
// unmount (a modal opened and froze the composer) — latching Input's
|
|
2769
|
+
// played-key so this trigger can never replay after a remount.
|
|
2770
|
+
const settledRef = useRef(false)
|
|
2771
|
+
const onSettledRef = useRef(props.onSettled)
|
|
2772
|
+
onSettledRef.current = props.onSettled
|
|
2773
|
+
const settle = (): void => {
|
|
2774
|
+
if (settledRef.current) return
|
|
2775
|
+
settledRef.current = true
|
|
2776
|
+
onSettledRef.current()
|
|
2777
|
+
}
|
|
2778
|
+
useEffect(() => {
|
|
2779
|
+
if (done) settle()
|
|
2780
|
+
}, [done])
|
|
2781
|
+
useEffect(() => () => {
|
|
2782
|
+
settle()
|
|
2783
|
+
}, [])
|
|
2784
|
+
if (!props.active || done || tick * DEEPSEEK_WAVE_TICK_MS >= durationMs) return props.fallback
|
|
2785
|
+
const hues = deepseekWaveHues(tier)
|
|
2786
|
+
const bandRgb = getPalette().composerBand
|
|
2787
|
+
const totalBandRows = props.rows.length + 2
|
|
2788
|
+
const waveBg = (row: number, column: number): string => {
|
|
2789
|
+
const rgb = deepseekWaveColumnBg(tick, column, props.bandWidth, tier, style, hues, bandRgb, row, totalBandRows)
|
|
2790
|
+
return rgb === null ? props.bandBg : inkColor(rgb)
|
|
2791
|
+
}
|
|
2792
|
+
const blankBandRow = (row: number): ReactElement => {
|
|
2793
|
+
const blanks: ComposerCell[] = []
|
|
2794
|
+
for (let column = 0; column < props.bandWidth; column += 1) {
|
|
2795
|
+
blanks.push({ char: ' ', width: 1, backgroundColor: waveBg(row, column) })
|
|
2796
|
+
}
|
|
2797
|
+
return createElement(Text, { key: `blank-${row}` }, ...waveRowSpans(blanks))
|
|
2798
|
+
}
|
|
2799
|
+
const editorWaveRows = props.rows.map((row, visibleIndex) => {
|
|
2800
|
+
const sourceIndex = props.windowStart + visibleIndex
|
|
2801
|
+
const bandRow = visibleIndex + 1
|
|
2802
|
+
const parts = editorRowParts(row, sourceIndex, props.caretRow, props.cursor)
|
|
2803
|
+
const placeholder = sourceIndex === 0 && props.value === ''
|
|
2804
|
+
const cells: ComposerCell[] = []
|
|
2805
|
+
let usedColumns = 0
|
|
2806
|
+
const push = (char: string, extra: Omit<ComposerCell, 'char' | 'width' | 'backgroundColor'> = {}): void => {
|
|
2807
|
+
const width = visibleColumns(char)
|
|
2808
|
+
cells.push({ char, width, backgroundColor: waveBg(bandRow, usedColumns), ...extra })
|
|
2809
|
+
usedColumns += width
|
|
2810
|
+
}
|
|
2811
|
+
if (sourceIndex === 0) {
|
|
2812
|
+
push(props.promptGlyph, { color: props.promptColor, bold: true })
|
|
2813
|
+
push(' ', { color: props.promptColor })
|
|
2814
|
+
} else {
|
|
2815
|
+
push(' ')
|
|
2816
|
+
push(' ')
|
|
2817
|
+
}
|
|
2818
|
+
for (const span of splitGraphemes(parts.before)) push(span.text)
|
|
2819
|
+
if (parts.hasCaret) push(parts.caret, { inverse: props.caretVisible })
|
|
2820
|
+
const tail = placeholder ? COMPOSER_PLACEHOLDER : parts.after
|
|
2821
|
+
for (const span of splitGraphemes(tail)) push(span.text, placeholder ? { dim: true } : {})
|
|
2822
|
+
while (usedColumns < props.bandWidth) push(' ')
|
|
2823
|
+
|
|
2824
|
+
const middleBandRow = Math.floor(totalBandRows / 2)
|
|
2825
|
+
if (bandRow === middleBandRow && deepseekWaveWordVisible(tick, tier, style)) {
|
|
2826
|
+
const word = tier === 'unknown' ? 'Into the Unknown' : 'deepseek'
|
|
2827
|
+
const start = Math.max(2, Math.floor((props.bandWidth - word.length) / 2))
|
|
2828
|
+
const indices = Array.from({ length: word.length }, (_, at) => cellIndexAtColumn(cells, start + at))
|
|
2829
|
+
if (indices.every(index => index !== undefined && (cells[index]!.char === ' ' || cells[index]!.dim === true))) {
|
|
2830
|
+
for (let at = 0; at < word.length; at += 1) {
|
|
2831
|
+
const cell = cells[indices[at]!]!
|
|
2832
|
+
cell.char = word[at]!
|
|
2833
|
+
cell.width = 1
|
|
2834
|
+
cell.color = inkColor(deepseekWaveWordHue(at, hues))
|
|
2835
|
+
cell.bold = true
|
|
2836
|
+
cell.dim = false
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
if (bandRow === middleBandRow && (tier === 'deepseek' || tier === 'unknown') && style === 'wave') {
|
|
2841
|
+
const spark = deepseekWaveSpark(tick)
|
|
2842
|
+
const lastIndex = cellIndexAtColumn(cells, props.bandWidth - 1)
|
|
2843
|
+
if (spark !== null && lastIndex !== undefined && cells[lastIndex]!.char === ' ') {
|
|
2844
|
+
cells[lastIndex]!.char = spark
|
|
2845
|
+
cells[lastIndex]!.color = props.promptColor
|
|
2846
|
+
cells[lastIndex]!.bold = true
|
|
2847
|
+
cells[lastIndex]!.dim = false
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
return createElement(Text, { key: `editor-${sourceIndex}`, wrap: 'truncate-end' }, ...waveRowSpans(cells))
|
|
2851
|
+
})
|
|
2852
|
+
return createElement(
|
|
2853
|
+
Box,
|
|
2854
|
+
{ flexDirection: 'column', width: props.bandWidth },
|
|
2855
|
+
blankBandRow(0),
|
|
2856
|
+
...editorWaveRows,
|
|
2857
|
+
blankBandRow(totalBandRows - 1),
|
|
2858
|
+
)
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2613
2861
|
/**
|
|
2614
2862
|
* The Ctrl+O transcript inspector: one selected durable entry at a time,
|
|
2615
2863
|
* with independent history selection and content scrolling. The complete
|
|
@@ -2821,8 +3069,12 @@ export function completionCandidates(
|
|
|
2821
3069
|
seen.add(name)
|
|
2822
3070
|
all.push(candidate)
|
|
2823
3071
|
}
|
|
2824
|
-
|
|
2825
|
-
|
|
3072
|
+
// Fuzzy ranking (the web menu's discovery feel): the query must be a
|
|
3073
|
+
// case-insensitive ordered subsequence of a name; prefix hits first, then
|
|
3074
|
+
// alignment score, then this composition order. An empty query keeps the
|
|
3075
|
+
// full list.
|
|
3076
|
+
return rankByName(all.map(candidate => ({ name: candidate.label.slice(1), candidate })), prefix)
|
|
3077
|
+
.map(entry => entry.candidate)
|
|
2826
3078
|
}
|
|
2827
3079
|
|
|
2828
3080
|
/**
|
|
@@ -2923,20 +3175,28 @@ interface DraftImage extends ImagePathInspection {
|
|
|
2923
3175
|
readonly marker: string
|
|
2924
3176
|
}
|
|
2925
3177
|
|
|
3178
|
+
/** One attached non-image file held in the editor until submission persists it. */
|
|
3179
|
+
interface DraftFile extends FilePathInspection {
|
|
3180
|
+
/** Visible draft token; deleting it also detaches the hidden path. */
|
|
3181
|
+
readonly marker: string
|
|
3182
|
+
}
|
|
3183
|
+
|
|
2926
3184
|
/**
|
|
2927
3185
|
* The prompt box: TUI-local slash commands handled locally, other lines
|
|
2928
3186
|
* dispatched; input editing keeps a cursor with history and completion.
|
|
2929
3187
|
* While a modal (approval / question / model panel) owns the keys, the
|
|
2930
3188
|
* box passes every key through untouched.
|
|
2931
3189
|
*/
|
|
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, cyclePermission, exportTranscript, renameTitle, copyLastResponse, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed, waveTier, waveStyle, maxRows, onEditorRows, onMenuRows }: {
|
|
3190
|
+
function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openEffort, openHelp, openMode, openPermission, openResume, openPlugin, openJobs, openStatusline, openTheme, openHistory, openAgents, openSubagent, openTodos, openDelete, openDiff, reviewChanges, deleteConfirm, confirmDelete, cancelDelete, createSession, forkSession, cancelSessionSwitch, notify, applyEditorKeys, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, inspectImages, prepareImages, inspectFiles, prepareFiles, cyclePermission, exportTranscript, renameTitle, copyLastResponse, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed, animations, applyAnimations, waveTier, waveStyle, maxRows, onEditorRows, onMenuRows, sessionKey }: {
|
|
2933
3191
|
active: boolean
|
|
2934
3192
|
frozen: boolean
|
|
2935
3193
|
busy: boolean
|
|
2936
3194
|
descriptors: readonly CommandDescriptor[]
|
|
2937
3195
|
skills: readonly SkillRow[]
|
|
2938
|
-
dispatch(text: string,
|
|
2939
|
-
steer(text: string,
|
|
3196
|
+
dispatch(text: string, attachments?: readonly ContentBlock[], origin?: string): void
|
|
3197
|
+
steer(text: string, attachments?: readonly ContentBlock[], origin?: string): void
|
|
3198
|
+
/** The full current session identity ('' while pending); the delivery origin. */
|
|
3199
|
+
sessionKey: string
|
|
2940
3200
|
interrupt(): boolean
|
|
2941
3201
|
quit(): void
|
|
2942
3202
|
openModel(): void
|
|
@@ -2981,6 +3241,8 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2981
3241
|
loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
|
|
2982
3242
|
inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
|
|
2983
3243
|
prepareImages(paths: readonly string[], signal?: AbortSignal): Promise<readonly ImageBlock[]>
|
|
3244
|
+
inspectFiles(paths: readonly string[]): Promise<readonly FilePathInspection[]>
|
|
3245
|
+
prepareFiles(paths: readonly string[], signal?: AbortSignal): Promise<readonly FileBlock[]>
|
|
2984
3246
|
cyclePermission(): string
|
|
2985
3247
|
exportTranscript(argument: string): Promise<void>
|
|
2986
3248
|
renameTitle(argument: string): string
|
|
@@ -2999,6 +3261,10 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2999
3261
|
historyFill: { text: string; index: number } | undefined
|
|
3000
3262
|
/** Marks the accepted entry consumed (called after the fill is applied). */
|
|
3001
3263
|
historyConsumed(): void
|
|
3264
|
+
/** Whether timed animations run (shimmer, chase, blink, wave). */
|
|
3265
|
+
animations: boolean
|
|
3266
|
+
/** Apply and report one /animation toggle (App persists through the runner). */
|
|
3267
|
+
applyAnimations(enabled: boolean): void
|
|
3002
3268
|
/** DeepSeek easter-egg wave tier of the applied route (null otherwise):
|
|
3003
3269
|
* official DeepSeek models drive their flash/pro tiers, non-DeepSeek
|
|
3004
3270
|
* models running an effort above high drive the "Into the Unknown"
|
|
@@ -3029,10 +3295,13 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3029
3295
|
const [draftImages, setDraftImages] = useState<readonly DraftImage[]>([])
|
|
3030
3296
|
const draftImagesRef = useRef(draftImages)
|
|
3031
3297
|
draftImagesRef.current = draftImages
|
|
3298
|
+
const [draftFiles, setDraftFiles] = useState<readonly DraftFile[]>([])
|
|
3299
|
+
const draftFilesRef = useRef(draftFiles)
|
|
3300
|
+
draftFilesRef.current = draftFiles
|
|
3032
3301
|
const [preparingImages, setPreparingImages] = useState(false)
|
|
3033
3302
|
const prepareAbortRef = useRef<AbortController | undefined>(undefined)
|
|
3034
3303
|
const prepareEpochRef = useRef(0)
|
|
3035
|
-
const { visible: cursorVisible, reset: resetCursorBlink } = useCursorBlink(active && !frozen && !preparingImages)
|
|
3304
|
+
const { visible: cursorVisible, reset: resetCursorBlink } = useCursorBlink(active && !frozen && !preparingImages && animations)
|
|
3036
3305
|
useEffect(() => () => {
|
|
3037
3306
|
prepareEpochRef.current += 1
|
|
3038
3307
|
prepareAbortRef.current?.abort()
|
|
@@ -3066,6 +3335,8 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3066
3335
|
const safe = sanitizeDraftText(historyFill.text)
|
|
3067
3336
|
draftImagesRef.current = []
|
|
3068
3337
|
setDraftImages([])
|
|
3338
|
+
draftFilesRef.current = []
|
|
3339
|
+
setDraftFiles([])
|
|
3069
3340
|
valueRef.current = safe
|
|
3070
3341
|
cursorRef.current = safe.length
|
|
3071
3342
|
setValue(safe)
|
|
@@ -3088,6 +3359,11 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3088
3359
|
draftImagesRef.current = next
|
|
3089
3360
|
return next.length === current.length ? current : next
|
|
3090
3361
|
})
|
|
3362
|
+
setDraftFiles((current) => {
|
|
3363
|
+
const next = current.filter(file => value.includes(file.marker))
|
|
3364
|
+
draftFilesRef.current = next
|
|
3365
|
+
return next.length === current.length ? current : next
|
|
3366
|
+
})
|
|
3091
3367
|
}, [value])
|
|
3092
3368
|
|
|
3093
3369
|
// Home/End and the Backspace-vs-Delete family never survive Ink's parser
|
|
@@ -3150,13 +3426,19 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3150
3426
|
process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right
|
|
3151
3427
|
)
|
|
3152
3428
|
|
|
3153
|
-
const uniqueImageMarker = (name: string, source: 'mention' | 'drop', reserved: readonly string[] = []): string => {
|
|
3429
|
+
const uniqueImageMarker = (name: string, source: 'mention' | 'drop', reserved: readonly string[] = [], kind: 'image' | 'file' = 'image'): string => {
|
|
3154
3430
|
const safeName = singleLineText(sanitizeDraftText(name))
|
|
3155
|
-
const
|
|
3431
|
+
const label = kind === 'file' ? 'file' : 'image'
|
|
3432
|
+
const base = source === 'mention' ? `@${safeName}` : `[${label}: ${safeName}]`
|
|
3156
3433
|
let marker = base
|
|
3157
3434
|
let suffix = 2
|
|
3158
|
-
|
|
3159
|
-
|
|
3435
|
+
const taken = (candidate: string): boolean =>
|
|
3436
|
+
valueRef.current.includes(candidate)
|
|
3437
|
+
|| draftImagesRef.current.some(image => image.marker === candidate)
|
|
3438
|
+
|| draftFilesRef.current.some(file => file.marker === candidate)
|
|
3439
|
+
|| reserved.includes(candidate)
|
|
3440
|
+
while (taken(marker)) {
|
|
3441
|
+
marker = source === 'mention' ? `@${safeName} (${suffix})` : `[${label}: ${safeName} ${suffix}]`
|
|
3160
3442
|
suffix += 1
|
|
3161
3443
|
}
|
|
3162
3444
|
return marker
|
|
@@ -3173,27 +3455,44 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3173
3455
|
return true
|
|
3174
3456
|
}
|
|
3175
3457
|
|
|
3176
|
-
|
|
3458
|
+
/**
|
|
3459
|
+
* Attach a paste/drop split into image and non-image paths: images ride the
|
|
3460
|
+
* durable image blocks, files ride the 0.1.5 file blocks, and both register
|
|
3461
|
+
* visible draft markers anchored at the drop point.
|
|
3462
|
+
*/
|
|
3463
|
+
const insertDroppedAttachments = (imagePaths: readonly string[], filePaths: readonly string[]): void => {
|
|
3177
3464
|
const originalValue = valueRef.current
|
|
3178
3465
|
const originalCursor = cursorRef.current
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3466
|
+
const total = imagePaths.length + filePaths.length
|
|
3467
|
+
if (total === 0) return
|
|
3468
|
+
notify(`checking ${total} attachment${total === 1 ? '' : 's'}…`)
|
|
3469
|
+
void Promise.all([
|
|
3470
|
+
imagePaths.length === 0 ? Promise.resolve([]) : inspectImages(imagePaths),
|
|
3471
|
+
filePaths.length === 0 ? Promise.resolve([]) : inspectFiles(filePaths),
|
|
3472
|
+
]).then(([inspectedImages, inspectedFiles]) => {
|
|
3473
|
+
const imageAdditions: DraftImage[] = []
|
|
3474
|
+
const fileAdditions: DraftFile[] = []
|
|
3182
3475
|
const markers: string[] = []
|
|
3183
|
-
for (const inspection of
|
|
3184
|
-
if ([...draftImagesRef.current, ...
|
|
3476
|
+
for (const inspection of inspectedImages) {
|
|
3477
|
+
if ([...draftImagesRef.current, ...imageAdditions].some(image => sameImagePath(image.path, inspection.path))) continue
|
|
3185
3478
|
const marker = uniqueImageMarker(inspection.name, 'drop', markers)
|
|
3186
|
-
|
|
3479
|
+
imageAdditions.push({ ...inspection, marker })
|
|
3187
3480
|
markers.push(marker)
|
|
3188
3481
|
}
|
|
3189
|
-
|
|
3190
|
-
|
|
3482
|
+
for (const inspection of inspectedFiles) {
|
|
3483
|
+
if ([...draftFilesRef.current, ...fileAdditions].some(file => sameImagePath(file.path, inspection.path))) continue
|
|
3484
|
+
const marker = uniqueImageMarker(inspection.name, 'drop', markers, 'file')
|
|
3485
|
+
fileAdditions.push({ ...inspection, marker })
|
|
3486
|
+
markers.push(marker)
|
|
3487
|
+
}
|
|
3488
|
+
if (imageAdditions.length === 0 && fileAdditions.length === 0) {
|
|
3489
|
+
notify('those attachments are already attached', 'warning')
|
|
3191
3490
|
return
|
|
3192
3491
|
}
|
|
3193
3492
|
const current = valueRef.current
|
|
3194
3493
|
const anchor = remapStableRange(originalValue, current, { start: originalCursor, end: originalCursor })
|
|
3195
3494
|
if (anchor === undefined) {
|
|
3196
|
-
notify('draft changed at the
|
|
3495
|
+
notify('draft changed at the attachment drop point; drop the files again', 'warning')
|
|
3197
3496
|
return
|
|
3198
3497
|
}
|
|
3199
3498
|
const at = anchor.start
|
|
@@ -3207,12 +3506,16 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3207
3506
|
setValue(edit.value)
|
|
3208
3507
|
setCursor(nextCursor)
|
|
3209
3508
|
resetCursorBlink()
|
|
3210
|
-
const nextImages = [...draftImagesRef.current, ...
|
|
3509
|
+
const nextImages = [...draftImagesRef.current, ...imageAdditions]
|
|
3211
3510
|
draftImagesRef.current = nextImages
|
|
3212
3511
|
setDraftImages(nextImages)
|
|
3213
|
-
|
|
3512
|
+
const nextFiles = [...draftFilesRef.current, ...fileAdditions]
|
|
3513
|
+
draftFilesRef.current = nextFiles
|
|
3514
|
+
setDraftFiles(nextFiles)
|
|
3515
|
+
const count = imageAdditions.length + fileAdditions.length
|
|
3516
|
+
notify(`${count} attachment${count === 1 ? '' : 's'} ready for the next message`)
|
|
3214
3517
|
}, (reason: unknown) => {
|
|
3215
|
-
notify(`
|
|
3518
|
+
notify(`attachment failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
|
|
3216
3519
|
})
|
|
3217
3520
|
}
|
|
3218
3521
|
|
|
@@ -3253,8 +3556,21 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3253
3556
|
const visibleMentionRows = mentionToken !== undefined && isPathLikeMentionQuery(mentionToken.query)
|
|
3254
3557
|
? mentionRows.filter(row => row.kind !== 'session')
|
|
3255
3558
|
: mentionRows
|
|
3559
|
+
// Fuzzy ordering over the upstream candidates (≤20 per page, cheaper than
|
|
3560
|
+
// the slash menu): rows whose name contains the typed query as an ordered
|
|
3561
|
+
// subsequence rise to the top by alignment, and every other upstream row
|
|
3562
|
+
// keeps its place after them — the upstream matcher has its own relevance
|
|
3563
|
+
// semantics (path segments), so ranking reorders but never drops rows. A
|
|
3564
|
+
// path-like query keeps the upstream order entirely.
|
|
3565
|
+
let rankedMentionRows = visibleMentionRows
|
|
3566
|
+
if (mentionToken !== undefined && !isPathLikeMentionQuery(mentionToken.query) && mentionToken.query !== '') {
|
|
3567
|
+
const hits = rankByName(visibleMentionRows.map(row => ({ name: row.label.replace(/^@/u, ''), row })), mentionToken.query)
|
|
3568
|
+
.map(entry => entry.row)
|
|
3569
|
+
const hitSet = new Set(hits)
|
|
3570
|
+
rankedMentionRows = [...hits, ...visibleMentionRows.filter(row => !hitSet.has(row))]
|
|
3571
|
+
}
|
|
3256
3572
|
const menuRows: readonly CompletionCandidate[] = mentionActive
|
|
3257
|
-
?
|
|
3573
|
+
? rankedMentionRows.map(row => ({
|
|
3258
3574
|
label: row.label.startsWith('@')
|
|
3259
3575
|
? row.label
|
|
3260
3576
|
: `@${row.label}${row.kind === 'directory' ? '/' : ''}`,
|
|
@@ -3270,8 +3586,8 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3270
3586
|
/** Accept the highlighted completion-menu candidate into the draft. */
|
|
3271
3587
|
const acceptMenuCandidate = (): void => {
|
|
3272
3588
|
if (mentionActive && mentionToken !== undefined) {
|
|
3273
|
-
if (
|
|
3274
|
-
const row =
|
|
3589
|
+
if (rankedMentionRows.length === 0) return
|
|
3590
|
+
const row = rankedMentionRows[completionIndex % rankedMentionRows.length]
|
|
3275
3591
|
if (row !== undefined) {
|
|
3276
3592
|
if (row.kind === 'file' && row.path !== undefined && looksLikeImagePath(row.path)) {
|
|
3277
3593
|
const tokenText = value.slice(mentionToken.start, cursor)
|
|
@@ -3510,6 +3826,8 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3510
3826
|
resetCursorBlink()
|
|
3511
3827
|
draftImagesRef.current = []
|
|
3512
3828
|
setDraftImages([])
|
|
3829
|
+
draftFilesRef.current = []
|
|
3830
|
+
setDraftFiles([])
|
|
3513
3831
|
setCompletionIndex(0)
|
|
3514
3832
|
setDismissedMenuValue(undefined)
|
|
3515
3833
|
} else {
|
|
@@ -3578,15 +3896,29 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3578
3896
|
// completion-inserted trailing space still routes `/quit ` correctly.
|
|
3579
3897
|
const trimmed = liveValue.trim()
|
|
3580
3898
|
const text = submissionPayload(liveValue)
|
|
3581
|
-
if (draftImagesRef.current.length > 0) {
|
|
3899
|
+
if (draftImagesRef.current.length > 0 || draftFilesRef.current.length > 0) {
|
|
3900
|
+
// Slash semantics with attachments are unchanged: commands cannot
|
|
3901
|
+
// carry attachments, so the line goes to the model as a prompt —
|
|
3902
|
+
// warn instead of surprising the user with a literal "/export".
|
|
3903
|
+
if (isSlashLine(text)) notify('commands cannot carry attachments; the line will be sent to the model as a prompt', 'warning')
|
|
3904
|
+
// Attachment prepares resolve asynchronously; the app remounts onto
|
|
3905
|
+
// another session in the meantime, and this (old) instance's unmount
|
|
3906
|
+
// cleanup runs too late on the microtask timeline. Tag the delivery
|
|
3907
|
+
// with the composing session so the runner can drop the stale one.
|
|
3908
|
+
const originSession = sessionKey
|
|
3582
3909
|
const controller = new AbortController()
|
|
3583
3910
|
const epoch = prepareEpochRef.current + 1
|
|
3584
3911
|
prepareEpochRef.current = epoch
|
|
3585
3912
|
prepareAbortRef.current = controller
|
|
3586
3913
|
setPreparingImages(true)
|
|
3587
|
-
|
|
3588
|
-
const
|
|
3589
|
-
|
|
3914
|
+
const imageSnapshot = draftImagesRef.current
|
|
3915
|
+
const fileSnapshot = draftFilesRef.current
|
|
3916
|
+
const total = imageSnapshot.length + fileSnapshot.length
|
|
3917
|
+
notify(`processing ${total} attachment${total === 1 ? '' : 's'}…`)
|
|
3918
|
+
void Promise.all([
|
|
3919
|
+
imageSnapshot.length === 0 ? Promise.resolve([]) : prepareImages(imageSnapshot.map(image => image.path), controller.signal),
|
|
3920
|
+
fileSnapshot.length === 0 ? Promise.resolve([]) : prepareFiles(fileSnapshot.map(file => file.path), controller.signal),
|
|
3921
|
+
]).then(([images, files]) => {
|
|
3590
3922
|
if (controller.signal.aborted || prepareEpochRef.current !== epoch) return
|
|
3591
3923
|
prepareAbortRef.current = undefined
|
|
3592
3924
|
setPreparingImages(false)
|
|
@@ -3596,6 +3928,8 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3596
3928
|
setCursor(0)
|
|
3597
3929
|
draftImagesRef.current = []
|
|
3598
3930
|
setDraftImages([])
|
|
3931
|
+
draftFilesRef.current = []
|
|
3932
|
+
setDraftFiles([])
|
|
3599
3933
|
setCompletionIndex(0)
|
|
3600
3934
|
setDismissedMenuValue(undefined)
|
|
3601
3935
|
dismissNotice()
|
|
@@ -3604,13 +3938,14 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3604
3938
|
recordHistory(text)
|
|
3605
3939
|
}
|
|
3606
3940
|
recall.current = beginRecall(recallSpace, '')
|
|
3607
|
-
|
|
3608
|
-
|
|
3941
|
+
const blocks: readonly ContentBlock[] = [...images, ...files]
|
|
3942
|
+
if (busy) steer(text, blocks, originSession)
|
|
3943
|
+
else dispatch(text, blocks, originSession)
|
|
3609
3944
|
}, (reason: unknown) => {
|
|
3610
3945
|
if (controller.signal.aborted || prepareEpochRef.current !== epoch) return
|
|
3611
3946
|
prepareAbortRef.current = undefined
|
|
3612
3947
|
setPreparingImages(false)
|
|
3613
|
-
notify(`
|
|
3948
|
+
notify(`attachment submission failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
|
|
3614
3949
|
})
|
|
3615
3950
|
return
|
|
3616
3951
|
}
|
|
@@ -3733,6 +4068,13 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3733
4068
|
openTheme()
|
|
3734
4069
|
return
|
|
3735
4070
|
}
|
|
4071
|
+
if (text === '/animation' || text.startsWith('/animation ')) {
|
|
4072
|
+
const parsed = parseAnimationsArgument(text.slice('/animation'.length))
|
|
4073
|
+
if (parsed === 'toggle') applyAnimations(!animations)
|
|
4074
|
+
else if (parsed === 'usage') notify('usage: /animation [on|off]', 'info')
|
|
4075
|
+
else applyAnimations(parsed.enabled)
|
|
4076
|
+
return
|
|
4077
|
+
}
|
|
3736
4078
|
if (text === '/history') {
|
|
3737
4079
|
openHistory()
|
|
3738
4080
|
return
|
|
@@ -3906,50 +4248,44 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3906
4248
|
text = text.replaceAll(PASTE_END_MARKER, '')
|
|
3907
4249
|
}
|
|
3908
4250
|
if (text === '') return
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
4251
|
+
if (text.length > 1) {
|
|
4252
|
+
// A path-list paste splits into images and files; prose falls through
|
|
4253
|
+
// as ordinary text (the splitter returns empty groups for non-paths).
|
|
4254
|
+
const dropped = parsePastedAttachmentPaths(text)
|
|
4255
|
+
if (dropped.images.length > 0 || dropped.files.length > 0) {
|
|
4256
|
+
insertDroppedAttachments(dropped.images, dropped.files)
|
|
4257
|
+
return
|
|
4258
|
+
}
|
|
3913
4259
|
}
|
|
3914
4260
|
applyEdit(insertText(valueRef.current, cursorRef.current, text))
|
|
3915
4261
|
}
|
|
3916
4262
|
}, active)
|
|
3917
4263
|
|
|
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])
|
|
4264
|
+
// The DeepSeek easter-egg wave renders through the ComposerWave leaf
|
|
4265
|
+
// below, which owns its 33ms tick: the sweep re-renders only that child at
|
|
4266
|
+
// 30fps — this component's editor model, menu, and derived state never
|
|
4267
|
+
// re-run per frame. App drives the tier/style pair on a model switch, and
|
|
4268
|
+
// the child remounts whenever that pair changes (App picks a NEW random
|
|
4269
|
+
// style for every replay, so the pair always differs when a wave should
|
|
4270
|
+
// run), resetting the timeline to frame 0 before the first paint.
|
|
4271
|
+
//
|
|
4272
|
+
// The sweep is strictly one-shot per trigger, and the latch lives HERE —
|
|
4273
|
+
// not in the leaf — because modal panels freeze the composer and UNMOUNT
|
|
4274
|
+
// ComposerWave; a mount-scoped latch would reset on every panel close and
|
|
4275
|
+
// replay a finished sweep. Keying `wavePlayedKey` by the tier:style pair
|
|
4276
|
+
// survives those unmounts: only a NEW trigger (which always changes the
|
|
4277
|
+
// pair) re-arms the sweep. Busy turns, image preparation, /animation
|
|
4278
|
+
// toggles, and panel open/close on an UNCHANGED model+effort pair never
|
|
4279
|
+
// fire it again.
|
|
4280
|
+
const waveKey = waveTier !== null && waveStyle !== null ? `${waveTier}:${waveStyle}` : null
|
|
4281
|
+
const [wavePlayedKey, setWavePlayedKey] = useState<string | null>(null)
|
|
3949
4282
|
useEffect(() => {
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
4283
|
+
// While animations are off, any pending trigger is consumed silently:
|
|
4284
|
+
// re-enabling must never queue or replay a celebration the user opted
|
|
4285
|
+
// out of watching.
|
|
4286
|
+
if (!animations && waveKey !== null && waveKey !== wavePlayedKey) setWavePlayedKey(waveKey)
|
|
4287
|
+
}, [animations, waveKey, wavePlayedKey])
|
|
4288
|
+
const waveArmed = waveKey !== null && waveKey !== wavePlayedKey
|
|
3953
4289
|
|
|
3954
4290
|
// Every exclusive panel keeps the composer as a stable visual anchor, but
|
|
3955
4291
|
// freezes it to one row: no menu, multiline wrap, or animation.
|
|
@@ -4049,7 +4385,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
4049
4385
|
? preparingImages
|
|
4050
4386
|
? createElement(Text, { color: inkColor(getPalette().warn), bold: true }, '… ')
|
|
4051
4387
|
: busy
|
|
4052
|
-
? createElement(BusyChase)
|
|
4388
|
+
? createElement(BusyChase, { animated: animations })
|
|
4053
4389
|
: createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, `${promptGlyph} `)
|
|
4054
4390
|
: ' ',
|
|
4055
4391
|
parts.before,
|
|
@@ -4064,104 +4400,35 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
4064
4400
|
}
|
|
4065
4401
|
const staticEditor = createElement(Box, { flexDirection: 'column' }, ...editorRows)
|
|
4066
4402
|
|
|
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
|
-
|
|
4403
|
+
// The wave paints the SAME visible rows and caret site as the static path
|
|
4404
|
+
// through the ComposerWave leaf (see its comment). The child remounts on
|
|
4405
|
+
// every tier/style change, so its timeline always starts at frame 0, and
|
|
4406
|
+
// its gate cancels — never freezes — the sweep while busy, preparing
|
|
4407
|
+
// images, or animations are off.
|
|
4158
4408
|
return createElement(
|
|
4159
4409
|
Box,
|
|
4160
4410
|
{ flexDirection: 'column' },
|
|
4161
4411
|
menu,
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
:
|
|
4412
|
+
createElement(ComposerWave, {
|
|
4413
|
+
key: waveKey ?? 'static',
|
|
4414
|
+
tier: waveTier ?? 'deepseek',
|
|
4415
|
+
style: waveStyle ?? 'wave',
|
|
4416
|
+
active: waveTier !== null && waveStyle !== null && !busy && !preparingImages && animations && waveArmed,
|
|
4417
|
+
onSettled: () => {
|
|
4418
|
+
if (waveKey !== null) setWavePlayedKey(waveKey)
|
|
4419
|
+
},
|
|
4420
|
+
fallback: band(staticEditor),
|
|
4421
|
+
bandWidth,
|
|
4422
|
+
bandBg,
|
|
4423
|
+
rows: editorViewModel.rows.slice(editorWindowStart, editorWindowStart + editorWindowRows),
|
|
4424
|
+
windowStart: editorWindowStart,
|
|
4425
|
+
caretRow: caret.row,
|
|
4426
|
+
cursor: clampedCursor,
|
|
4427
|
+
caretVisible: cursorVisible,
|
|
4428
|
+
value,
|
|
4429
|
+
promptGlyph,
|
|
4430
|
+
promptColor,
|
|
4431
|
+
}),
|
|
4165
4432
|
)
|
|
4166
4433
|
}
|
|
4167
4434
|
|
|
@@ -4431,12 +4698,21 @@ export function App(props: AppProps): ReactElement {
|
|
|
4431
4698
|
* to static while the prompt marker keeps the tier accent. The trigger
|
|
4432
4699
|
* follows the applied model label (what the status bar actually shows),
|
|
4433
4700
|
* 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
|
-
*
|
|
4701
|
+
* cached at the switch. The 33ms tick itself lives inside the ComposerWave
|
|
4702
|
+
* leaf, so the sweep re-renders only the composer band, not the whole tree,
|
|
4703
|
+
* at 30fps; App owns the rarely-changing tier/style and the leaf plays the
|
|
4704
|
+
* sweep exactly ONCE per pair change — an unchanged model+effort pair
|
|
4705
|
+
* (ordinary turns, image preparation, /animation toggles) never replays. */
|
|
4438
4706
|
const [waveTier, setWaveTier] = useState<DeepseekWaveTier | null>(null)
|
|
4439
4707
|
const [waveStyle, setWaveStyle] = useState<DeepseekWaveStyle | null>(null)
|
|
4708
|
+
// /animation toggle: applies immediately, persists through the runner, and
|
|
4709
|
+
// gates every timed leaf (shimmer, chase, blink, wave) for this render.
|
|
4710
|
+
const [animations, setAnimations] = useState(props.animations ?? true)
|
|
4711
|
+
const applyAnimations = (enabled: boolean): void => {
|
|
4712
|
+
setAnimations(enabled)
|
|
4713
|
+
props.saveAnimations?.(enabled)
|
|
4714
|
+
notify(`animations ${enabled ? 'on' : 'off'}`)
|
|
4715
|
+
}
|
|
4440
4716
|
const previousModel = useRef<string | undefined>(undefined)
|
|
4441
4717
|
const previousEffort = useRef<string | undefined>(props.effort)
|
|
4442
4718
|
const previousStyle = useRef<DeepseekWaveStyle | undefined>(undefined)
|
|
@@ -4668,7 +4944,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
4668
4944
|
// contract that lets arbitrarily long conversations scroll instead of
|
|
4669
4945
|
// freezing when the live tree exceeds the terminal height. The dynamic
|
|
4670
4946
|
// region below stays small: the streaming tail, modals, composer, and its
|
|
4671
|
-
// status footer.
|
|
4947
|
+
// status footer. Live stream frames preserve `entries` identity.
|
|
4672
4948
|
//
|
|
4673
4949
|
// `computeSettledRows` extends the cached row set incrementally: the
|
|
4674
4950
|
// settled prefix is permanently final, so a grown boundary builds ONLY the
|
|
@@ -5114,7 +5390,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
5114
5390
|
// marker falls back to the static dim row — same as Deep diving
|
|
5115
5391
|
// always yields the live region to streaming content.
|
|
5116
5392
|
: view.streaming === ''
|
|
5117
|
-
? createElement(ShimmerLine, { text: '✻ Thinking… (Ctrl/Alt+R to expand)' })
|
|
5393
|
+
? createElement(ShimmerLine, { text: '✻ Thinking… (Ctrl/Alt+R to expand)', animated: animations })
|
|
5118
5394
|
: createElement(StreamTail, {
|
|
5119
5395
|
text: 'Thinking… (Ctrl/Alt+R to expand)',
|
|
5120
5396
|
prefix: '✻ ',
|
|
@@ -5129,10 +5405,10 @@ export function App(props: AppProps): ReactElement {
|
|
|
5129
5405
|
// The same two-column gutter as settled replies: streamed text
|
|
5130
5406
|
// lands exactly where the assembled message will render.
|
|
5131
5407
|
{ text: view.streaming, dim: false, maxRows: auditedAnswerRows, prefix: ' ' },
|
|
5132
|
-
busy ? createElement(Caret) : undefined,
|
|
5408
|
+
busy ? createElement(Caret, { animated: animations }) : undefined,
|
|
5133
5409
|
)
|
|
5134
5410
|
: undefined,
|
|
5135
|
-
deepDivingVisible ? createElement(DeepDivingLine, { since: view.busySince }) : undefined,
|
|
5411
|
+
deepDivingVisible ? createElement(DeepDivingLine, { since: view.busySince, animated: animations }) : undefined,
|
|
5136
5412
|
)
|
|
5137
5413
|
: undefined,
|
|
5138
5414
|
transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : undefined,
|
|
@@ -5419,6 +5695,9 @@ export function App(props: AppProps): ReactElement {
|
|
|
5419
5695
|
loadMentions: props.loadMentions,
|
|
5420
5696
|
inspectImages: props.inspectImages,
|
|
5421
5697
|
prepareImages: props.prepareImages,
|
|
5698
|
+
inspectFiles: props.inspectFiles,
|
|
5699
|
+
prepareFiles: props.prepareFiles,
|
|
5700
|
+
sessionKey: props.sessionKey,
|
|
5422
5701
|
cyclePermission: props.cyclePermission,
|
|
5423
5702
|
exportTranscript: props.exportTranscript,
|
|
5424
5703
|
renameTitle: props.renameTitle,
|
|
@@ -5430,6 +5709,8 @@ export function App(props: AppProps): ReactElement {
|
|
|
5430
5709
|
cancelQueued: props.cancelQueued,
|
|
5431
5710
|
historyFill,
|
|
5432
5711
|
historyConsumed,
|
|
5712
|
+
animations,
|
|
5713
|
+
applyAnimations,
|
|
5433
5714
|
waveTier,
|
|
5434
5715
|
waveStyle,
|
|
5435
5716
|
maxRows: composerEditorCap,
|