dsh-code 0.9.1 → 1.0.1
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 +278 -249
- package/README.md +131 -102
- package/bin/deepseek.mjs +100 -6
- package/cordis.patch.yml +36 -1
- package/lib/index.mjs +3055 -819
- package/lib/startup.mjs +21 -11
- package/lib/{theme-BEi4i_aN.mjs → theme-DCT8Y2xf.mjs} +13 -9
- package/lib/types/app.d.ts +84 -16
- package/lib/types/attachments.d.ts +20 -0
- package/lib/types/authorization-panel.d.ts +22 -0
- package/lib/types/authorization.d.ts +36 -0
- package/lib/types/editor.d.ts +6 -0
- package/lib/types/fork.d.ts +8 -0
- package/lib/types/git-workflow.d.ts +23 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/kernel-panels.d.ts +39 -0
- package/lib/types/keyboard.d.ts +41 -0
- package/lib/types/mentions.d.ts +30 -38
- package/lib/types/models.d.ts +3 -1
- package/lib/types/permissions.d.ts +4 -14
- package/lib/types/presets.d.ts +5 -20
- package/lib/types/provider-settings.d.ts +16 -0
- package/lib/types/render/animations.d.ts +10 -39
- package/lib/types/render/editor.d.ts +137 -0
- package/lib/types/render/export.d.ts +1 -1
- package/lib/types/render/lines.d.ts +6 -2
- package/lib/types/render/markdown.d.ts +3 -1
- package/lib/types/render/projection.d.ts +29 -3
- package/lib/types/render/status.d.ts +6 -13
- package/lib/types/session-directory.d.ts +1 -3
- package/lib/types/startup.d.ts +14 -11
- package/lib/types/store.d.ts +11 -9
- package/lib/types/subagents.d.ts +3 -3
- package/lib/types/theme.d.ts +14 -1
- package/lib/types/version.d.ts +15 -2
- package/package.json +159 -141
- package/src/app.ts +1490 -663
- package/src/attachments.ts +128 -0
- package/src/authorization-panel.ts +285 -0
- package/src/authorization.ts +147 -0
- package/src/editor.ts +51 -0
- package/src/fork.ts +31 -0
- package/src/git-workflow.ts +87 -0
- package/src/index.ts +1523 -1374
- package/src/internals.ts +14 -1
- package/src/kernel-panels.ts +914 -798
- package/src/keyboard.ts +126 -0
- package/src/mentions.ts +78 -117
- package/src/models.ts +20 -14
- package/src/permissions.ts +5 -13
- package/src/presets.ts +6 -22
- package/src/provider-settings.ts +95 -1
- package/src/render/animations.ts +420 -450
- package/src/render/editor.ts +398 -0
- package/src/render/export.ts +79 -79
- package/src/render/lines.ts +342 -236
- package/src/render/markdown.ts +99 -26
- package/src/render/projection.ts +106 -19
- package/src/render/status.ts +713 -650
- package/src/render/text.ts +150 -150
- package/src/render/tool-detail.ts +3 -1
- package/src/session-directory.ts +4 -4
- package/src/startup.ts +136 -119
- package/src/store.ts +23 -11
- package/src/subagents.ts +13 -5
- package/src/theme.ts +214 -206
- package/src/version.ts +58 -1
package/src/app.ts
CHANGED
|
@@ -14,18 +14,18 @@
|
|
|
14
14
|
* @module @deepseek-ai/dsh-code/app
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import { basename } from 'node:path'
|
|
18
|
+
import {
|
|
18
19
|
createElement, memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactElement,
|
|
19
20
|
} from 'react'
|
|
20
|
-
import { Box, Static, Text, useInput, useStdout, type Key } from 'ink'
|
|
21
|
-
import {
|
|
22
|
-
import type {
|
|
23
|
-
import type { TodoItem } from '@deepseek-ai/dsh-session'
|
|
24
|
-
import type { AskUserQuestionAnswerItem } from '@deepseek-ai/dsh-user-questions'
|
|
21
|
+
import { Box, Static, Text, useInput, useStdin, useStdout, type Key } from 'ink'
|
|
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-session'
|
|
25
|
+
import type { AskUserQuestionAnswerItem } from '@deepseek-ai/dsh-user-questions'
|
|
26
|
+
import type { AuthorizationInteraction, AuthorizationStatus } from '@deepseek-ai/dsh-authorization'
|
|
25
27
|
import {
|
|
26
|
-
brand,
|
|
27
28
|
dim,
|
|
28
|
-
error as paintError,
|
|
29
29
|
getPalette,
|
|
30
30
|
getTheme,
|
|
31
31
|
inkColor,
|
|
@@ -35,16 +35,14 @@ import {
|
|
|
35
35
|
} from './theme.ts'
|
|
36
36
|
import { ThemePanel } from './theme-panel.ts'
|
|
37
37
|
import { WHALE_GLYPH, WHALE_GLYPH_COLUMNS } from './whale-glyph.ts'
|
|
38
|
-
import { DSH_CODE_VERSION } from './version.ts'
|
|
38
|
+
import { DSH_CODE_VERSION, dshKernelVersion } from './version.ts'
|
|
39
39
|
import type { TranscriptStore } from './store.ts'
|
|
40
40
|
import { settledEntryCount, type TranscriptEntry } from './render/projection.ts'
|
|
41
|
-
import {
|
|
42
|
-
import type { ToolDetail } from './render/tool-detail.ts'
|
|
41
|
+
import { type MdSegment, visibleColumns } from './render/markdown.ts'
|
|
43
42
|
import {
|
|
44
43
|
busyChaseFrame,
|
|
45
44
|
caretVisible,
|
|
46
45
|
DEEPSEEK_WAVE_TICK_MS,
|
|
47
|
-
deepseekWaveBorderColor,
|
|
48
46
|
deepseekWaveColumnBg,
|
|
49
47
|
deepseekWaveDuration,
|
|
50
48
|
deepseekWaveSpark,
|
|
@@ -54,25 +52,23 @@ import {
|
|
|
54
52
|
deepseekWaveWordVisible,
|
|
55
53
|
effortAboveHigh,
|
|
56
54
|
isOfficialDeepSeekLabel,
|
|
57
|
-
pulseFrame,
|
|
58
|
-
WAVE_BASE_DARK,
|
|
59
|
-
WAVE_BASE_LIGHT,
|
|
60
55
|
type DeepseekWaveStyle,
|
|
61
56
|
type DeepseekWaveTier,
|
|
62
57
|
} from './render/animations.ts'
|
|
63
58
|
import type { ApprovalSnapshot, ApprovalStore } from './approval.ts'
|
|
64
59
|
import type { CommandsView } from './commands.ts'
|
|
65
60
|
import type { ModelDirectory, ModelRow } from './models.ts'
|
|
66
|
-
import type { ProviderSettingsDirectory, ProviderTargetView } from './provider-settings.ts'
|
|
61
|
+
import type { ProviderConfiguration, ProviderSettingsDirectory, ProviderTargetView } from './provider-settings.ts'
|
|
67
62
|
import type { QuestionSnapshot, QuestionStore } from './questions.ts'
|
|
68
63
|
import type { SkillsView, SkillRow } from './skills.ts'
|
|
69
64
|
import type { MentionCandidate } from './mentions.ts'
|
|
70
65
|
import type { SubagentFeedView, SubagentRow } from './subagents.ts'
|
|
71
|
-
import { AgentsPanel, EffortPanel,
|
|
66
|
+
import { AgentsPanel, EffortPanel, HistoryPanel, JobsPanel, ModePanel, PermissionPanel, PluginPanel, ResumePanel, StatuslinePanel, runClock, SubagentPanel, type JobRow } from './kernel-panels.ts'
|
|
72
67
|
import type { PresetRow } from './presets.ts'
|
|
73
68
|
import type { PermissionRow } from './permissions.ts'
|
|
74
69
|
import type { PluginRow } from './plugin-inventory.ts'
|
|
75
70
|
import {
|
|
71
|
+
beginRecall,
|
|
76
72
|
recallEntries,
|
|
77
73
|
recallNewer,
|
|
78
74
|
recallOlder,
|
|
@@ -80,14 +76,57 @@ import {
|
|
|
80
76
|
type RecallState,
|
|
81
77
|
} from './history.ts'
|
|
82
78
|
import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
|
|
79
|
+
import type { GitDiffView } from './git-workflow.ts'
|
|
80
|
+
import {
|
|
81
|
+
authorizationForProvider,
|
|
82
|
+
providerAuthorizationStatus,
|
|
83
|
+
type ProviderAuthorizationDirectory,
|
|
84
|
+
type ProviderAuthorizationRow,
|
|
85
|
+
} from './authorization.ts'
|
|
86
|
+
import { ProviderAuthorizationLogoutPanel, ProviderAuthorizationPanel } from './authorization-panel.ts'
|
|
87
|
+
import {
|
|
88
|
+
looksLikeImagePath,
|
|
89
|
+
parsePastedImagePaths,
|
|
90
|
+
type ImagePathInspection,
|
|
91
|
+
} from './attachments.ts'
|
|
83
92
|
|
|
84
93
|
/** Match Codex's settled-resize window before rebuilding terminal scrollback. */
|
|
85
94
|
const RESIZE_REFLOW_DELAY_MS = 75
|
|
86
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Cap on rendered settled history, in physical rows (header and hint
|
|
98
|
+
* included). 3,000 rows sits inside Codex's 1k–10k reflow budget range:
|
|
99
|
+
* replays stay under ~200ms while roughly a hundred messages stay visible
|
|
100
|
+
* before the oldest drop out. `DSH_SETTLED_ROWS` overrides it; 0 disables
|
|
101
|
+
* the cap entirely (the historical unbounded behavior).
|
|
102
|
+
*/
|
|
103
|
+
const SETTLED_ROW_CAP = readSettledRowCap()
|
|
104
|
+
/** Hysteresis: the cap may overflow by 25% before one trimming replay fires. */
|
|
105
|
+
/** Header rows plus the trim hint, reserved out of the row cap. */
|
|
106
|
+
const SETTLED_ROW_RESERVE = 12
|
|
107
|
+
|
|
108
|
+
/** Read the configurable settled-history cap once per process. */
|
|
109
|
+
function readSettledRowCap(): number {
|
|
110
|
+
const raw = process.env.DSH_SETTLED_ROWS
|
|
111
|
+
if (raw === undefined) return 3_000
|
|
112
|
+
const parsed = Number.parseInt(raw, 10)
|
|
113
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : 3_000
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Safety net for a bracketed paste whose end marker never arrives (terminal
|
|
118
|
+
* defect or crash mid-paste): past this window the open-paste flag resets so
|
|
119
|
+
* Enter submits again instead of inserting newlines forever.
|
|
120
|
+
*/
|
|
121
|
+
const PASTE_BRACKET_TIMEOUT_MS = 1_000
|
|
122
|
+
|
|
87
123
|
/** Reset region/style, clear the visible screen and scrollback, then home. */
|
|
88
124
|
const RESIZE_REFLOW_CLEAR = '\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H'
|
|
125
|
+
/** Ask terminals supporting DEC synchronized updates to hold the frame. */
|
|
126
|
+
const SYNCHRONIZED_UPDATE_BEGIN = '\x1b[?2026h'
|
|
127
|
+
/** Release the held frame after Ink has replayed the source-backed Static rows. */
|
|
128
|
+
const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l'
|
|
89
129
|
import {
|
|
90
|
-
formatTokens,
|
|
91
130
|
layoutStatusBar,
|
|
92
131
|
parseStatuslineItems,
|
|
93
132
|
STATUS_CYCLE_HINT,
|
|
@@ -101,6 +140,7 @@ import {
|
|
|
101
140
|
type StatusTone,
|
|
102
141
|
} from './render/status.ts'
|
|
103
142
|
import { displayTail, displayText, singleLineText, truncateColumns } from './render/text.ts'
|
|
143
|
+
import { normalizeKeyboardChunk, PASTE_END_MARKER, PASTE_START_MARKER, stripPasteMarkers } from './keyboard.ts'
|
|
104
144
|
import {
|
|
105
145
|
clampScroll,
|
|
106
146
|
followInspectorCursor,
|
|
@@ -114,16 +154,69 @@ import {
|
|
|
114
154
|
import {
|
|
115
155
|
lineSegment,
|
|
116
156
|
markdownLines,
|
|
117
|
-
|
|
157
|
+
settledEntryLines,
|
|
118
158
|
styledLines,
|
|
119
159
|
textLines,
|
|
120
160
|
transcriptEntryLines,
|
|
121
161
|
type LineStyle,
|
|
122
162
|
type StyledLine,
|
|
123
163
|
} from './render/lines.ts'
|
|
164
|
+
import {
|
|
165
|
+
caretSite,
|
|
166
|
+
clampCursor,
|
|
167
|
+
composerMaxRows,
|
|
168
|
+
deleteBackward,
|
|
169
|
+
deleteForward,
|
|
170
|
+
deleteLastGrapheme,
|
|
171
|
+
deleteWordBackward,
|
|
172
|
+
deleteWordForward,
|
|
173
|
+
editorModel,
|
|
174
|
+
insertText,
|
|
175
|
+
type EditResult,
|
|
176
|
+
killToLineEnd,
|
|
177
|
+
killToLineStart,
|
|
178
|
+
lineBounds,
|
|
179
|
+
moveCursorBy,
|
|
180
|
+
moveCursorVertically,
|
|
181
|
+
moveWordLeft,
|
|
182
|
+
moveWordRight,
|
|
183
|
+
sanitizeDraftText,
|
|
184
|
+
shouldRecallNavigate,
|
|
185
|
+
splitGraphemes,
|
|
186
|
+
} from './render/editor.ts'
|
|
124
187
|
|
|
125
|
-
/** Visual priority for one bounded local notice. */
|
|
126
|
-
export type NoticeTone = 'info' | 'warning' | 'error'
|
|
188
|
+
/** Visual priority for one bounded local notice. */
|
|
189
|
+
export type NoticeTone = 'info' | 'warning' | 'error'
|
|
190
|
+
|
|
191
|
+
/** One source of truth for TUI-owned slash commands in completion and `/help`. */
|
|
192
|
+
const LOCAL_COMMANDS = [
|
|
193
|
+
{ label: '/help', description: 'show this overlay' },
|
|
194
|
+
{ label: '/model', description: 'switch the model and manage providers' },
|
|
195
|
+
{ label: '/effort', description: 'adjust reasoning effort for the current model' },
|
|
196
|
+
{ label: '/mode', description: 'inspect or select the agent preset (/mode [preset])' },
|
|
197
|
+
{ label: '/permission', description: 'inspect or select the permission preset (/permission [preset])' },
|
|
198
|
+
{ label: '/new', description: 'create and switch to a fresh session (/new [preset])' },
|
|
199
|
+
{ label: '/fork', description: 'fork at the latest completed turn (/fork [event-seq])' },
|
|
200
|
+
{ label: '/resume', description: 'browse or switch root sessions (/resume [id|prefix])' },
|
|
201
|
+
{ label: '/plugin', description: 'inspect the live plugin composition' },
|
|
202
|
+
{ label: '/jobs', description: 'inspect background jobs' },
|
|
203
|
+
{ label: '/statusline', description: 'customize the status line items' },
|
|
204
|
+
{ label: '/theme', description: 'switch the color theme' },
|
|
205
|
+
{ label: '/history', description: 'search and recall past prompts' },
|
|
206
|
+
{ label: '/agents', description: 'inspect subagent sessions of this conversation' },
|
|
207
|
+
{ label: '/todos', description: 'inspect the full todo list' },
|
|
208
|
+
{ label: '/subagent', description: 'choose the model delegated subagents run on' },
|
|
209
|
+
{ label: '/delete', description: 'delete a session and its subagent threads' },
|
|
210
|
+
{ label: '/clear', description: 'clear the screen' },
|
|
211
|
+
{ label: '/export', description: 'export the transcript to markdown (/export [path])' },
|
|
212
|
+
{ label: '/title', description: 'rename this session (/title <text>)' },
|
|
213
|
+
{ label: '/copy', description: 'copy the latest assistant response' },
|
|
214
|
+
{ label: '/diff', description: 'inspect Git changes (/diff [--staged|ref])' },
|
|
215
|
+
{ label: '/review', description: 'review Git changes under read-only permissions' },
|
|
216
|
+
{ label: '/quit', description: 'exit' },
|
|
217
|
+
] as const
|
|
218
|
+
|
|
219
|
+
const LOCAL_COMMAND_NAMES = new Set(LOCAL_COMMANDS.map(command => command.label.slice(1)))
|
|
127
220
|
|
|
128
221
|
/** Props the runner hands the app; callbacks stay owned by the runner. */
|
|
129
222
|
export interface AppProps {
|
|
@@ -158,9 +251,9 @@ export interface AppProps {
|
|
|
158
251
|
/** Permission preset selected for the current or pending first session. */
|
|
159
252
|
permission: string
|
|
160
253
|
/** Submit one line: slash commands to the registry, other text to the agent. */
|
|
161
|
-
dispatch(text: string): void
|
|
162
|
-
/** Submit steering: consumed at the running turn's next step boundary. */
|
|
163
|
-
steer(text: string): void
|
|
254
|
+
dispatch(text: string, images?: readonly ImageBlock[]): void
|
|
255
|
+
/** Submit steering: consumed at the running turn's next step boundary. */
|
|
256
|
+
steer(text: string, images?: readonly ImageBlock[]): void
|
|
164
257
|
/** Interrupt the running turn (Esc); true when a turn was cancelled. */
|
|
165
258
|
interrupt(): boolean
|
|
166
259
|
/** Quit: unmount, flush, and request process exit. */
|
|
@@ -168,7 +261,11 @@ export interface AppProps {
|
|
|
168
261
|
/** Load the selectable model directory (called when /model opens). */
|
|
169
262
|
loadModels(): Promise<ModelDirectory>
|
|
170
263
|
/** Load @mention candidates for the typed query (files + sessions). */
|
|
171
|
-
loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
|
|
264
|
+
loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
|
|
265
|
+
/** Validate draft image paths without committing attachment objects. */
|
|
266
|
+
inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
|
|
267
|
+
/** Validate, normalize and persist images immediately before submission. */
|
|
268
|
+
prepareImages(paths: readonly string[]): Promise<readonly ImageBlock[]>
|
|
172
269
|
/** Apply one /model selection (with an advertised reasoning effort, when picked); returns the display label. */
|
|
173
270
|
selectModel(row: ModelRow, effortId?: string): string
|
|
174
271
|
/** The /subagent override label, '' when delegated agents follow the current model. */
|
|
@@ -189,6 +286,21 @@ export interface AppProps {
|
|
|
189
286
|
unsetModelProviderCredential?(target: ProviderTargetView): Promise<void>
|
|
190
287
|
/** Remove one user-owned provider profile and its page-managed credential. */
|
|
191
288
|
removeModelProvider?(target: ProviderTargetView): Promise<void>
|
|
289
|
+
/** Save endpoint and explicit model capacities through the provider profile. */
|
|
290
|
+
saveModelProviderConfiguration?(target: ProviderTargetView, configuration: ProviderConfiguration): Promise<void>
|
|
291
|
+
/** Provider authorization flows and value-free stored-record facts. */
|
|
292
|
+
loadProviderAuthorizations?(): Promise<ProviderAuthorizationDirectory>
|
|
293
|
+
subscribeProviderAuthorizations?(listener: () => void): () => void
|
|
294
|
+
beginProviderAuthorization?(
|
|
295
|
+
row: ProviderAuthorizationRow,
|
|
296
|
+
method: string,
|
|
297
|
+
interaction: AuthorizationInteraction,
|
|
298
|
+
signal: AbortSignal,
|
|
299
|
+
): Promise<AuthorizationStatus>
|
|
300
|
+
cancelProviderAuthorization?(row: ProviderAuthorizationRow): void
|
|
301
|
+
logoutProviderAuthorization?(row: ProviderAuthorizationRow): Promise<void>
|
|
302
|
+
openAuthorizationUrl?(url: string): boolean
|
|
303
|
+
copyTextValue?(text: string): Promise<void>
|
|
192
304
|
/** Cycle to the next permission preset (Shift+Tab); returns the new label. */
|
|
193
305
|
cyclePermission(): string
|
|
194
306
|
/** Select or inspect a permission preset without requiring a pre-existing session. */
|
|
@@ -197,12 +309,20 @@ export interface AppProps {
|
|
|
197
309
|
exportTranscript(argument: string): Promise<void>
|
|
198
310
|
/** Rename the session (/title <text>); returns the outcome line for the notice. */
|
|
199
311
|
renameTitle(argument: string): string
|
|
312
|
+
/** Copy the latest complete assistant response; resolves to notice text. */
|
|
313
|
+
copyLastResponse(): Promise<string>
|
|
314
|
+
/** Load a complete read-only Git diff for the file-oriented viewport. */
|
|
315
|
+
loadGitDiff(argument: string): Promise<GitDiffView>
|
|
316
|
+
/** Start a model review after applying the read-only permission preset. */
|
|
317
|
+
reviewChanges(argument: string): void
|
|
200
318
|
/** Preset/session/plugin kernel operations. */
|
|
201
319
|
loadPresets(): Promise<readonly PresetRow[]>
|
|
202
320
|
switchMode(id: string): Promise<string>
|
|
203
321
|
/** Load the switchable permission presets for the /permission panel. */
|
|
204
322
|
loadPermissions(): Promise<readonly PermissionRow[]>
|
|
205
323
|
createSession(mode?: string): void
|
|
324
|
+
/** Fork the active session at a completed-turn boundary. */
|
|
325
|
+
forkSession(argument: string): void
|
|
206
326
|
loadSessions(options: SessionDirectoryOptions, signal?: AbortSignal): Promise<readonly SessionRow[]>
|
|
207
327
|
loadSessionTranscript(id: string, signal?: AbortSignal): Promise<string>
|
|
208
328
|
/** Load this session's subagent conversations (children by lineage). */
|
|
@@ -210,6 +330,8 @@ export interface AppProps {
|
|
|
210
330
|
switchSession(row: SessionRow): void
|
|
211
331
|
cancelSessionSwitch(): boolean
|
|
212
332
|
loadPlugins(): readonly PluginRow[]
|
|
333
|
+
/** Caller-visible background jobs (the host jobs registry, read-only). */
|
|
334
|
+
loadJobs(): readonly JobRow[]
|
|
213
335
|
/** Registers the app's notice channel with the runner (called once on mount). */
|
|
214
336
|
onBridgeReady(bridge: { notify(text: string, tone?: NoticeTone): void }): void
|
|
215
337
|
/** Ordered enabled status items (/statusline config); the runner owns persistence. */
|
|
@@ -259,12 +381,6 @@ function useStableInput(handler: (input: string, key: Key) => void, active: bool
|
|
|
259
381
|
useInput(stableHandler, { isActive: active })
|
|
260
382
|
}
|
|
261
383
|
|
|
262
|
-
/** Single-cell stepped pulse: the web's 125ms flat-hold brightness steps over 1s. */
|
|
263
|
-
function Pulse(): ReactElement {
|
|
264
|
-
const tick = useFrames(125)
|
|
265
|
-
return createElement(Text, { color: inkColor(getPalette().brandBright) }, pulseFrame(tick))
|
|
266
|
-
}
|
|
267
|
-
|
|
268
384
|
/**
|
|
269
385
|
* The web StateDot "ongoing" chase in terminal form: three cells of the 3×3
|
|
270
386
|
* ring trail clockwise around the eight outer positions (8 frames × 125ms =
|
|
@@ -288,14 +404,6 @@ function CursorBlock({ char }: { char: string }): ReactElement {
|
|
|
288
404
|
return createElement(Text, { inverse: caretVisible(tick) || undefined }, char)
|
|
289
405
|
}
|
|
290
406
|
|
|
291
|
-
/** Web TurnStatus elapsed format: `45s` under a minute, `2m03s` beyond. */
|
|
292
|
-
function runClock(ms: number): string {
|
|
293
|
-
const total = Math.max(0, Math.floor(ms / 1000))
|
|
294
|
-
const minutes = Math.floor(total / 60)
|
|
295
|
-
const seconds = total % 60
|
|
296
|
-
return minutes > 0 ? `${minutes}m${String(seconds).padStart(2, '0')}s` : `${seconds}s`
|
|
297
|
-
}
|
|
298
|
-
|
|
299
407
|
/**
|
|
300
408
|
* The busy line, web TurnStatus contract: the StateDot chase leads the plain
|
|
301
409
|
* `Deep diving...` label, with the elapsed clock appended only once the turn
|
|
@@ -335,11 +443,11 @@ function StreamTail({ text, dim, maxRows, prefix = '', continuationPrefix = pref
|
|
|
335
443
|
}): ReactElement {
|
|
336
444
|
const columns = useStdout().stdout?.columns ?? 80
|
|
337
445
|
const safeRows = Math.max(1, maxRows)
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
//
|
|
446
|
+
// The final extra column keeps a caret from wrapping onto an unbudgeted
|
|
447
|
+
// row. Both prefixes participate because every physical row repeats its
|
|
448
|
+
// hanging indent.
|
|
341
449
|
const prefixColumns = Math.max(visibleColumns(prefix), visibleColumns(continuationPrefix))
|
|
342
|
-
const contentColumns = Math.max(10, columns -
|
|
450
|
+
const contentColumns = Math.max(10, columns - 1 - prefixColumns)
|
|
343
451
|
const initial = displayTail(text, contentColumns, safeRows)
|
|
344
452
|
// Reserve one row for the omission marker only when a marker is needed.
|
|
345
453
|
const tail = initial.truncated && safeRows > 1
|
|
@@ -434,241 +542,84 @@ function StyledRows({ lines }: { lines: readonly StyledLine[] }): ReactElement {
|
|
|
434
542
|
)
|
|
435
543
|
}
|
|
436
544
|
|
|
437
|
-
/**
|
|
438
|
-
function
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
545
|
+
/** File-oriented, color-coded unified diff viewport. */
|
|
546
|
+
function DiffPanel({ view, onClose }: { view: GitDiffView; onClose(): void }): ReactElement {
|
|
547
|
+
const stdout = useStdout().stdout
|
|
548
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
549
|
+
const [fileIndex, setFileIndex] = useState(0)
|
|
550
|
+
const [scroll, setScroll] = useState(0)
|
|
551
|
+
const file = view.files[fileIndex]
|
|
552
|
+
const lines = useMemo(() => {
|
|
553
|
+
if (file === undefined) return textLines(' (no changes)', viewport.contentColumns, 'dim')
|
|
554
|
+
return file.lines.flatMap(line => styledLines([
|
|
555
|
+
lineSegment(line, line.startsWith('+') && !line.startsWith('+++')
|
|
556
|
+
? 'success'
|
|
557
|
+
: line.startsWith('-') && !line.startsWith('---')
|
|
558
|
+
? 'error'
|
|
559
|
+
: line.startsWith('@@') || line.startsWith('diff --git') || line.startsWith('index ')
|
|
560
|
+
? 'brand'
|
|
561
|
+
: 'dim'),
|
|
562
|
+
], viewport.contentColumns))
|
|
563
|
+
}, [file, viewport.contentColumns])
|
|
564
|
+
const visibleScroll = clampScroll(scroll, lines.length, viewport.bodyRows)
|
|
565
|
+
useInput((input, key) => {
|
|
566
|
+
if (key.escape || input === 'q') onClose()
|
|
567
|
+
else if (key.leftArrow && view.files.length > 0) {
|
|
568
|
+
setFileIndex(current => (current + view.files.length - 1) % view.files.length)
|
|
569
|
+
setScroll(0)
|
|
570
|
+
} else if (key.rightArrow && view.files.length > 0) {
|
|
571
|
+
setFileIndex(current => (current + 1) % view.files.length)
|
|
572
|
+
setScroll(0)
|
|
573
|
+
}
|
|
574
|
+
else if (input === 'g') setScroll(0)
|
|
575
|
+
else if (input === 'G') setScroll(Math.max(0, lines.length - viewport.bodyRows))
|
|
576
|
+
else if (key.upArrow) setScroll(current => moveScroll(current, -1, lines.length, viewport.bodyRows))
|
|
577
|
+
else if (key.downArrow) setScroll(current => moveScroll(current, 1, lines.length, viewport.bodyRows))
|
|
578
|
+
else if (key.pageUp) setScroll(current => moveScroll(current, -viewport.bodyRows, lines.length, viewport.bodyRows))
|
|
579
|
+
else if (key.pageDown) setScroll(current => moveScroll(current, viewport.bodyRows, lines.length, viewport.bodyRows))
|
|
580
|
+
})
|
|
581
|
+
if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(`${view.title} · ${view.files.length} files · esc/q close`, viewport.contentColumns))
|
|
452
582
|
return createElement(
|
|
453
583
|
Box,
|
|
454
|
-
{ flexDirection: 'column',
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
: line.segments.map((segment, at) => createElement(Text, { key: at, ...segmentProps(segment.style) }, segment.text)),
|
|
461
|
-
)),
|
|
584
|
+
{ flexDirection: 'column', borderStyle: 'round', borderColor: inkColor(getPalette().dim), paddingX: 1 },
|
|
585
|
+
createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`${view.title} · ${view.files.length === 0 ? 'no files' : `${fileIndex + 1}/${view.files.length} ${file?.path ?? ''}`} · rows ${lines.length === 0 ? 0 : visibleScroll + 1}-${Math.min(lines.length, visibleScroll + viewport.bodyRows)}/${lines.length}`, viewport.contentColumns)),
|
|
586
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
587
|
+
createElement(StyledRows, { lines: lines.slice(visibleScroll, visibleScroll + viewport.bodyRows) }),
|
|
588
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
589
|
+
createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns('↑/↓ scroll · g/G ends · esc/q close', viewport.contentColumns)),
|
|
462
590
|
)
|
|
463
591
|
}
|
|
464
592
|
|
|
465
|
-
/**
|
|
466
|
-
function
|
|
467
|
-
|
|
468
|
-
const lines = useMemo(() => reasoningLines(text, Math.max(10, columns - 2)), [text, columns])
|
|
469
|
-
return createElement(StyledRows, { lines })
|
|
593
|
+
/** Codex-style panel rhythm that still participates in the row budget. */
|
|
594
|
+
function PanelGap({ visible }: { visible: boolean }): ReactElement | undefined {
|
|
595
|
+
return visible ? createElement(Text, null, ' ') : undefined
|
|
470
596
|
}
|
|
471
597
|
|
|
472
|
-
/**
|
|
473
|
-
* One expanded tool-card body for the verbose transcript (Ctrl+O): the
|
|
474
|
-
* presentation contract's structured cards — inline diffs, read windows,
|
|
475
|
-
* web sources — rendered as plain terminal rows, degradation-safe against
|
|
476
|
-
* replayed metadata.
|
|
477
|
-
*/
|
|
478
|
-
function ToolDetailBody({ detail }: { detail: ToolDetail }): ReactElement {
|
|
479
|
-
switch (detail.kind) {
|
|
480
|
-
case 'diff':
|
|
481
|
-
return createElement(
|
|
482
|
-
Box,
|
|
483
|
-
{ flexDirection: 'column' },
|
|
484
|
-
...detail.diffs.map((diff, index) => createElement(
|
|
485
|
-
Box,
|
|
486
|
-
{ key: index, flexDirection: 'column' },
|
|
487
|
-
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, ` ── ${displayText(diff.path)}${diff.truncated ? ' (diff truncated)' : ''}`),
|
|
488
|
-
...diff.lines.map((line, at) => createElement(
|
|
489
|
-
Text,
|
|
490
|
-
{
|
|
491
|
-
key: at,
|
|
492
|
-
color: line.mark === '+' ? inkColor(getPalette().success) : line.mark === '-' ? inkColor(getPalette().error) : inkColor(getPalette().dim),
|
|
493
|
-
wrap: 'truncate-end',
|
|
494
|
-
},
|
|
495
|
-
` ${line.mark}${displayText(line.text)}`,
|
|
496
|
-
)),
|
|
497
|
-
)),
|
|
498
|
-
)
|
|
499
|
-
case 'read':
|
|
500
|
-
return createElement(
|
|
501
|
-
Box,
|
|
502
|
-
{ flexDirection: 'column' },
|
|
503
|
-
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, ` ── ${displayText(detail.path)} · lines ${detail.offset}-${detail.lines.length > 0 ? detail.lines[detail.lines.length - 1]!.number : detail.offset - 1} of ${detail.totalLines}${detail.truncated ? ' (window truncated)' : ''}`),
|
|
504
|
-
...detail.lines.map((line, at) => createElement(
|
|
505
|
-
Text,
|
|
506
|
-
{ key: at, dimColor: true, wrap: 'truncate-end' },
|
|
507
|
-
` ${String(line.number).padStart(5, ' ')} | ${displayText(line.text)}`,
|
|
508
|
-
)),
|
|
509
|
-
)
|
|
510
|
-
case 'web-search':
|
|
511
|
-
return createElement(
|
|
512
|
-
Box,
|
|
513
|
-
{ flexDirection: 'column' },
|
|
514
|
-
...detail.sources.map((source, at) => createElement(
|
|
515
|
-
Text,
|
|
516
|
-
{ key: at, wrap: 'truncate-end' },
|
|
517
|
-
brand(` ? ${displayText(source.title === undefined ? source.url : source.title)}`),
|
|
518
|
-
createElement(Text, { dimColor: true }, dim(` - ${displayText(source.url)}`)),
|
|
519
|
-
)),
|
|
520
|
-
createElement(Text, { dimColor: true }, dim(` ${detail.sources.length} sources${detail.truncated ? ' (capped)' : ''}`)),
|
|
521
|
-
)
|
|
522
|
-
case 'web-fetch':
|
|
523
|
-
return createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(` ${displayText(detail.url)} · HTTP ${detail.statusCode}`))
|
|
524
|
-
case 'raw':
|
|
525
|
-
return createElement(
|
|
526
|
-
Box,
|
|
527
|
-
{ flexDirection: 'column' },
|
|
528
|
-
...displayText(detail.text).split('\n').slice(0, 40).map((line, at) => createElement(Text, { key: at, dimColor: true, wrap: 'truncate-end' }, ` ${line}`)),
|
|
529
|
-
createElement(Text, { dimColor: true }, detail.truncated ? ' … (output truncated)' : ' (end of output)'),
|
|
530
|
-
)
|
|
531
|
-
default:
|
|
532
|
-
return assertNever(detail, 'tool detail kind')
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
/** One settled transcript row. */
|
|
536
|
-
function EntryLine({ entry, showReasoning, verbose }: { entry: TranscriptEntry; showReasoning: boolean; verbose: boolean }): ReactElement {
|
|
537
|
-
switch (entry.kind) {
|
|
538
|
-
case 'user':
|
|
539
|
-
// Collapsed injected context reads as a dim ↳ row; only direct human
|
|
540
|
-
// prompts get the brand ❯ (they are different surfaces, not the same).
|
|
541
|
-
return entry.notice
|
|
542
|
-
? createElement(Text, { dimColor: true }, `⤷ ${displayText(entry.text)}`)
|
|
543
|
-
: createElement(Text, null, brand('❯ '), displayText(entry.text))
|
|
544
|
-
case 'assistant':
|
|
545
|
-
// Claude-Code-style thinking: a dim ✻ marker collapsed, the reasoning
|
|
546
|
-
// text dim-italic expanded (Ctrl+R toggles globally). The collapsed
|
|
547
|
-
// row is static — an animated counter inside the text would jitter the
|
|
548
|
-
// line width every frame. The reply body carries the same two-column
|
|
549
|
-
// gutter as the composer, so reply text aligns with the input cursor
|
|
550
|
-
// (Codex LIVE_PREFIX alignment).
|
|
551
|
-
return createElement(
|
|
552
|
-
Box,
|
|
553
|
-
{ flexDirection: 'column' },
|
|
554
|
-
entry.reasoning === ''
|
|
555
|
-
? undefined
|
|
556
|
-
: showReasoning
|
|
557
|
-
? createElement(ReasoningBody, { text: entry.reasoning })
|
|
558
|
-
: createElement(Text, { color: inkColor(getPalette().dim) }, `✻ Thinking (${entry.reasoning.length} chars, Ctrl+R to expand)`),
|
|
559
|
-
createElement(MarkdownBody, { text: entry.text, indent: 2 }),
|
|
560
|
-
)
|
|
561
|
-
case 'tool': {
|
|
562
|
-
// Claude-Code-style tool card: the invocation row plus a nested ⎿
|
|
563
|
-
// result line, so the summary reads under its call instead of inline.
|
|
564
|
-
const mark = entry.state === 'running'
|
|
565
|
-
? createElement(Pulse)
|
|
566
|
-
: entry.state === 'error'
|
|
567
|
-
? createElement(Text, { color: inkColor(getPalette().error) }, '⨯')
|
|
568
|
-
: createElement(Text, { color: inkColor(getPalette().success) }, '⏺')
|
|
569
|
-
return createElement(
|
|
570
|
-
Box,
|
|
571
|
-
{ flexDirection: 'column' },
|
|
572
|
-
createElement(
|
|
573
|
-
Text,
|
|
574
|
-
{ wrap: verbose ? 'truncate-end' : undefined },
|
|
575
|
-
mark,
|
|
576
|
-
' ',
|
|
577
|
-
brand(displayText(entry.name)),
|
|
578
|
-
entry.preview === '' ? '' : ` ${dim(displayText(entry.preview))}`,
|
|
579
|
-
),
|
|
580
|
-
entry.summary === ''
|
|
581
|
-
? undefined
|
|
582
|
-
: createElement(
|
|
583
|
-
Text,
|
|
584
|
-
{ color: entry.state === 'error' ? inkColor(getPalette().error) : inkColor(getPalette().dim), wrap: verbose ? 'truncate-end' : undefined },
|
|
585
|
-
` ⎿ ${displayText(entry.summary)}`,
|
|
586
|
-
),
|
|
587
|
-
verbose && entry.detail !== undefined
|
|
588
|
-
? createElement(ToolDetailBody, { detail: entry.detail })
|
|
589
|
-
: undefined,
|
|
590
|
-
)
|
|
591
|
-
}
|
|
592
|
-
case 'command': {
|
|
593
|
-
const mark = entry.state === 'running'
|
|
594
|
-
? createElement(Pulse)
|
|
595
|
-
: entry.state === 'error'
|
|
596
|
-
? createElement(Text, { color: inkColor(getPalette().error) }, '⨯')
|
|
597
|
-
: createElement(Text, { color: inkColor(getPalette().success) }, '⏺')
|
|
598
|
-
return createElement(
|
|
599
|
-
Box,
|
|
600
|
-
{ flexDirection: 'column' },
|
|
601
|
-
createElement(
|
|
602
|
-
Text,
|
|
603
|
-
{ wrap: verbose ? 'truncate-end' : undefined },
|
|
604
|
-
mark,
|
|
605
|
-
' ',
|
|
606
|
-
brand(displayText(`/${entry.name}`)),
|
|
607
|
-
entry.args === '' ? '' : ` ${dim(displayText(entry.args))}`,
|
|
608
|
-
),
|
|
609
|
-
entry.summary === ''
|
|
610
|
-
? undefined
|
|
611
|
-
: createElement(Text, { color: inkColor(getPalette().dim), wrap: verbose ? 'truncate-end' : undefined }, ` ⎿ ${displayText(entry.summary)}`),
|
|
612
|
-
)
|
|
613
|
-
}
|
|
614
|
-
case 'turn-marker':
|
|
615
|
-
// Non-error turn outcomes (cancel, ceiling, interruption) as dim rows.
|
|
616
|
-
return createElement(Text, { dimColor: true, wrap: verbose ? 'truncate-end' : undefined }, ` ⏹ ${displayText(entry.text)}`)
|
|
617
|
-
case 'compaction':
|
|
618
|
-
// Completed compaction lifecycle: what it reclaimed, or why it failed.
|
|
619
|
-
return createElement(
|
|
620
|
-
Text,
|
|
621
|
-
{ dimColor: true, wrap: verbose ? 'truncate-end' : undefined },
|
|
622
|
-
entry.ok
|
|
623
|
-
? ` ⧉ compacted ~${formatTokens(entry.tokens)} tokens`
|
|
624
|
-
: ` ⧉ compaction failed: ${displayText(entry.error)}`,
|
|
625
|
-
)
|
|
626
|
-
case 'retry':
|
|
627
|
-
// Provider-routed retry: amber while the backoff waits, dim once the
|
|
628
|
-
// next attempt is underway.
|
|
629
|
-
return createElement(
|
|
630
|
-
Text,
|
|
631
|
-
{ color: entry.state === 'running' ? inkColor(getPalette().warn) : inkColor(getPalette().dim), wrap: verbose ? 'truncate-end' : undefined },
|
|
632
|
-
` ↻ retry ${entry.attempt}/${entry.max} · ${displayText(entry.code)} · ${Math.round(entry.delayMs / 100) / 10}s`,
|
|
633
|
-
)
|
|
634
|
-
case 'files': {
|
|
635
|
-
// Turn-tail deliverables: the turn's mutated files (web turnTail chips).
|
|
636
|
-
const shown = entry.paths.slice(0, 3).map(path => displayText(path)).join(' · ')
|
|
637
|
-
const more = entry.paths.length > 3 ? ` (+${entry.paths.length - 3} more)` : ''
|
|
638
|
-
return createElement(Text, { dimColor: true, wrap: verbose ? 'truncate-end' : undefined }, ` ⎄ ${shown}${more}`)
|
|
639
|
-
}
|
|
640
|
-
case 'pending':
|
|
641
|
-
// Codex PendingSteer: queued prompts render as ordinary user rows; the
|
|
642
|
-
// durable user/message retires them seamlessly.
|
|
643
|
-
return createElement(
|
|
644
|
-
Text,
|
|
645
|
-
{ wrap: verbose ? 'truncate-end' : undefined },
|
|
646
|
-
brand('❯ '),
|
|
647
|
-
displayText(entry.text),
|
|
648
|
-
)
|
|
649
|
-
case 'error':
|
|
650
|
-
return createElement(Text, { wrap: verbose ? 'truncate-end' : undefined }, paintError(displayText(entry.text)))
|
|
651
|
-
default:
|
|
652
|
-
return assertNever(entry, 'transcript entry kind')
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
598
|
|
|
656
599
|
/**
|
|
657
|
-
* The whale header with a compact
|
|
658
|
-
*
|
|
659
|
-
*
|
|
660
|
-
*
|
|
600
|
+
* The whale header with a compact copy lockup. The dsh kernel version (when
|
|
601
|
+
* the host manifest resolves), the title, the bilingual slogan, and the key
|
|
602
|
+
* hint stay centered inside the existing eight content rows, preserving the
|
|
603
|
+
* Static header's ten physical rows; without a resolvable host the lockup
|
|
604
|
+
* keeps its historical three lines. Short or narrow terminals keep a one-line
|
|
605
|
+
* form without the kernel line.
|
|
661
606
|
*/
|
|
662
607
|
function Header({ resumed }: { resumed: boolean }): ReactElement {
|
|
663
608
|
const stdout = useStdout().stdout
|
|
664
609
|
const rows = stdout?.rows ?? 40
|
|
665
610
|
const columns = stdout?.columns ?? 80
|
|
611
|
+
const kernelLine = (() => {
|
|
612
|
+
const version = dshKernelVersion()
|
|
613
|
+
return version === undefined ? undefined : `dsh-v${version}`
|
|
614
|
+
})()
|
|
666
615
|
const title = `DeepSeek Harness · v${DSH_CODE_VERSION}`
|
|
667
616
|
const slogan = 'Into the Unknown 探索未至之境'
|
|
668
617
|
const hint = resumed ? 'resumed · /help · Esc interrupt' : '/help · Esc interrupt · Ctrl+C quit'
|
|
669
|
-
const
|
|
618
|
+
const copyWidths = [visibleColumns(title), visibleColumns(slogan), visibleColumns(hint)]
|
|
619
|
+
if (kernelLine !== undefined) copyWidths.push(visibleColumns(kernelLine))
|
|
620
|
+
const copyColumns = Math.max(...copyWidths)
|
|
670
621
|
const compact = `${title} · ${hint}`
|
|
671
|
-
if (rows < 20 || columns < WHALE_GLYPH_COLUMNS + copyColumns +
|
|
622
|
+
if (rows < 20 || columns < WHALE_GLYPH_COLUMNS + copyColumns + 10) {
|
|
672
623
|
return createElement(
|
|
673
624
|
Box,
|
|
674
625
|
{ width: Math.max(1, columns - 1), borderStyle: 'round', borderColor: inkColor(getPalette().brand), paddingX: 1 },
|
|
@@ -679,7 +630,10 @@ function Header({ resumed }: { resumed: boolean }): ReactElement {
|
|
|
679
630
|
Box,
|
|
680
631
|
// alignSelf shrinks the border to the whale-plus-copy content instead of
|
|
681
632
|
// stretching across the terminal and stranding empty space on the right.
|
|
682
|
-
|
|
633
|
+
// paddingX: 2 keeps a comfortable margin between the border and both the
|
|
634
|
+
// whale on the left and the copy on the right (each side gains one
|
|
635
|
+
// column over the previous paddingX: 1) without changing row height.
|
|
636
|
+
{ flexDirection: 'row', gap: 2, borderStyle: 'round', borderColor: inkColor(getPalette().brand), paddingX: 2, alignSelf: 'flex-start' },
|
|
683
637
|
createElement(
|
|
684
638
|
Box,
|
|
685
639
|
{ flexDirection: 'column', width: WHALE_GLYPH_COLUMNS, justifyContent: 'center' },
|
|
@@ -688,6 +642,9 @@ function Header({ resumed }: { resumed: boolean }): ReactElement {
|
|
|
688
642
|
createElement(
|
|
689
643
|
Box,
|
|
690
644
|
{ flexDirection: 'column', width: copyColumns, justifyContent: 'center' },
|
|
645
|
+
...(kernelLine === undefined
|
|
646
|
+
? []
|
|
647
|
+
: [createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, kernelLine)]),
|
|
691
648
|
createElement(Text, { color: inkColor(getPalette().brandBright), bold: true, wrap: 'truncate-end' }, title),
|
|
692
649
|
createElement(
|
|
693
650
|
Text,
|
|
@@ -745,10 +702,74 @@ function TodoPanel({ todos }: { todos: readonly TodoItem[] }): ReactElement | un
|
|
|
745
702
|
`todos ${completed}/${todos.length}`,
|
|
746
703
|
createElement(Text, { dimColor: true }, ` · ${inProgress} active · ${pending} pending`),
|
|
747
704
|
current === undefined ? '' : createElement(Text, { color: inkColor(getPalette().brandBright) }, ` · ${todoMark(current.status)} ${displayText(current.content)}`),
|
|
705
|
+
createElement(Text, { color: inkColor(getPalette().dim) }, ' · /todos'),
|
|
748
706
|
),
|
|
749
707
|
)
|
|
750
708
|
}
|
|
751
709
|
|
|
710
|
+
/**
|
|
711
|
+
* The /todos subpage: the full todo list in one bounded, scrollable panel.
|
|
712
|
+
* The live tree's TodoPanel stays a one-row summary; this exclusive view
|
|
713
|
+
* shows EVERY item with its three-state mark inside the shared panel
|
|
714
|
+
* viewport (same contract as /help and Ctrl+O: border/title/body/footer all
|
|
715
|
+
* ride one height budget, the composer and status stay put below).
|
|
716
|
+
*/
|
|
717
|
+
function TodoListPanel({ todos, onClose }: { todos: readonly TodoItem[]; onClose: () => void }): ReactElement {
|
|
718
|
+
const stdout = useStdout().stdout
|
|
719
|
+
const columns = stdout?.columns ?? 80
|
|
720
|
+
const viewport = panelViewport(columns, stdout?.rows ?? 30)
|
|
721
|
+
const [scroll, setScroll] = useState(0)
|
|
722
|
+
const bodyColumns = Math.max(4, viewport.contentColumns - 4)
|
|
723
|
+
const completed = todos.filter(todo => todo.status === 'completed').length
|
|
724
|
+
const inProgress = todos.filter(todo => todo.status === 'in_progress').length
|
|
725
|
+
const pending = todos.length - completed - inProgress
|
|
726
|
+
const rows = todos.length === 0
|
|
727
|
+
? [createElement(Text, { key: 'empty', dimColor: true, wrap: 'truncate-end' }, ' no todos yet')]
|
|
728
|
+
: todos.map(todo => createElement(
|
|
729
|
+
Text,
|
|
730
|
+
{ key: todo.content, dimColor: true, wrap: 'truncate-end' },
|
|
731
|
+
` ${todoMark(todo.status)} ${truncateColumns(displayText(todo.content), bodyColumns)}`,
|
|
732
|
+
))
|
|
733
|
+
const visibleScroll = clampScroll(scroll, rows.length, viewport.bodyRows)
|
|
734
|
+
const scrollBy = (delta: number): void => {
|
|
735
|
+
setScroll(current => moveScroll(current, delta, rows.length, viewport.bodyRows))
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
useEffect(() => {
|
|
739
|
+
if (visibleScroll !== scroll) setScroll(visibleScroll)
|
|
740
|
+
}, [visibleScroll, scroll])
|
|
741
|
+
|
|
742
|
+
useInput((input, key) => {
|
|
743
|
+
if (key.escape || input === 'q') {
|
|
744
|
+
onClose()
|
|
745
|
+
return
|
|
746
|
+
}
|
|
747
|
+
if (key.upArrow) scrollBy(-1)
|
|
748
|
+
else if (key.downArrow) scrollBy(1)
|
|
749
|
+
else if (key.pageUp) scrollBy(-Math.max(1, viewport.bodyRows - 1))
|
|
750
|
+
else if (key.pageDown) scrollBy(Math.max(1, viewport.bodyRows - 1))
|
|
751
|
+
else if (input === 'g') setScroll(0)
|
|
752
|
+
else if (input === 'G') setScroll(Math.max(0, rows.length - viewport.bodyRows))
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
|
|
756
|
+
if (viewport.compact) {
|
|
757
|
+
return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('todos · esc/q close', viewport.contentColumns))
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return createElement(
|
|
761
|
+
Box,
|
|
762
|
+
{ flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
|
|
763
|
+
createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`todos · ${completed}/${todos.length} done · ${inProgress} active · ${pending} pending · rows ${rows.length === 0 ? 0 : visibleScroll + 1}-${Math.min(rows.length, visibleScroll + viewport.bodyRows)}/${rows.length}`, viewport.contentColumns)),
|
|
764
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
765
|
+
...rows.slice(visibleScroll, visibleScroll + viewport.bodyRows),
|
|
766
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
767
|
+
createElement(Text, { dimColor: true, wrap: 'truncate-end' }, dim(truncateColumns('↑↓ scroll · pgup/pgdn page · g/G ends · esc/q close', viewport.contentColumns))),
|
|
768
|
+
)
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const MemoTodoListPanel = memo(TodoListPanel)
|
|
772
|
+
|
|
752
773
|
/**
|
|
753
774
|
* Ink props for one status tone: the Codex status-line accent mapping over
|
|
754
775
|
* the DeepSeek palette, all blue by design — the status bar speaks only in
|
|
@@ -808,7 +829,7 @@ function statusToneProps(tone: StatusTone): {
|
|
|
808
829
|
* truncation degrades groups, it never wraps a row.
|
|
809
830
|
*
|
|
810
831
|
* The DeepSeek easter egg: when the model label *switches* to an official
|
|
811
|
-
* DeepSeek route, the composer's INPUT ROW (
|
|
832
|
+
* DeepSeek route, the composer's INPUT ROW (the band's middle) plays Codex's
|
|
812
833
|
* effort-ignition "Wave" — a blue crest sweeping the content row column by
|
|
813
834
|
* column, with the `· ✦ ✧` sparkles on the deepseek tier — and the prompt
|
|
814
835
|
* marker keeps the tier accent afterwards. The border stays a constant
|
|
@@ -836,7 +857,30 @@ function StatusLine({ facts, stats, busy, columns, items }: {
|
|
|
836
857
|
columns: number
|
|
837
858
|
items: readonly string[]
|
|
838
859
|
}): ReactElement {
|
|
839
|
-
const layout = layoutStatusBar(facts, stats, Math.max(8, columns - 2), {
|
|
860
|
+
const layout = useMemo(() => layoutStatusBar(facts, stats, Math.max(8, columns - 2), {
|
|
861
|
+
busy,
|
|
862
|
+
items,
|
|
863
|
+
// Match the composer content budget: border + horizontal padding are
|
|
864
|
+
// already excluded, and layoutStatusBar shrinks this ceiling as needed.
|
|
865
|
+
contextWidth: Math.max(5, columns - 6),
|
|
866
|
+
}), [
|
|
867
|
+
facts.model,
|
|
868
|
+
facts.mode,
|
|
869
|
+
facts.cwd,
|
|
870
|
+
facts.branch,
|
|
871
|
+
facts.sessionId,
|
|
872
|
+
facts.title,
|
|
873
|
+
facts.sandbox,
|
|
874
|
+
facts.plan,
|
|
875
|
+
facts.permission,
|
|
876
|
+
facts.goal?.phase,
|
|
877
|
+
facts.goal?.rounds,
|
|
878
|
+
facts.goal?.max,
|
|
879
|
+
stats,
|
|
880
|
+
busy,
|
|
881
|
+
columns,
|
|
882
|
+
items,
|
|
883
|
+
])
|
|
840
884
|
|
|
841
885
|
const renderRow = (row: { left: readonly StatusGroup[]; right: readonly StatusSpan[]; hint: boolean }, key: string, indent = 0): ReactElement => {
|
|
842
886
|
const leftParts: ReactElement[] = []
|
|
@@ -871,8 +915,8 @@ function StatusLine({ facts, stats, busy, columns, items }: {
|
|
|
871
915
|
// of wrapping.
|
|
872
916
|
return createElement(
|
|
873
917
|
Box,
|
|
874
|
-
// Match the prompt text inside the
|
|
875
|
-
//
|
|
918
|
+
// Match the prompt text inside the composer band: two padding columns.
|
|
919
|
+
// The secondary row adds the model-name indent
|
|
876
920
|
// (its budget already shrinks by the same amount) so its figures align
|
|
877
921
|
// under the model name rather than under the busy dot.
|
|
878
922
|
{ paddingLeft: 2 + indent, justifyContent: rightParts.length > 0 ? 'space-between' : undefined },
|
|
@@ -1258,11 +1302,14 @@ function QuestionBar({ store, snapshot, locked }: { store: QuestionStore; snapsh
|
|
|
1258
1302
|
returnToOptions()
|
|
1259
1303
|
return
|
|
1260
1304
|
}
|
|
1261
|
-
setCustom(current => current
|
|
1305
|
+
setCustom(current => deleteLastGrapheme(current))
|
|
1262
1306
|
return
|
|
1263
1307
|
}
|
|
1264
1308
|
if (input !== '' && !key.ctrl && !key.meta) {
|
|
1265
|
-
|
|
1309
|
+
// Panel drafts see paste markers as literal text (Ink strips only the
|
|
1310
|
+
// leading ESC); strip them so a pasted answer never persists "[200~".
|
|
1311
|
+
const text = stripPasteMarkers(input)
|
|
1312
|
+
if (text !== '') setCustom(current => current + text)
|
|
1266
1313
|
}
|
|
1267
1314
|
return
|
|
1268
1315
|
}
|
|
@@ -1441,9 +1488,10 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
|
|
|
1441
1488
|
createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model — select model${rows.length === 0 ? '' : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)),
|
|
1442
1489
|
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1443
1490
|
...visibleStateRows,
|
|
1444
|
-
...visible.map((row) => {
|
|
1445
|
-
const index = rows.indexOf(row)
|
|
1446
|
-
const
|
|
1491
|
+
...visible.map((row) => {
|
|
1492
|
+
const index = rows.indexOf(row)
|
|
1493
|
+
const capability = row.inputModalities?.includes('image') === true ? ' · image' : ''
|
|
1494
|
+
const label = displayText(`${row.providerName} · ${row.modelName}${capability}`)
|
|
1447
1495
|
return createElement(
|
|
1448
1496
|
Text,
|
|
1449
1497
|
{
|
|
@@ -1473,12 +1521,17 @@ function providerStateLabel(row: ProviderTargetView): string {
|
|
|
1473
1521
|
}
|
|
1474
1522
|
|
|
1475
1523
|
/** The provider-management stage reached from /model with `a`. */
|
|
1476
|
-
function ProviderPanel({ directory, error, onCredential, onUnset, onRemove, onRetry, onBack }: {
|
|
1477
|
-
directory: ProviderSettingsDirectory | undefined
|
|
1478
|
-
error: string | undefined
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1524
|
+
function ProviderPanel({ directory, error, authorizations, authorizationError, onCredential, onConfigure, onUnset, onRemove, onLogin, onLogout, onRetry, onBack }: {
|
|
1525
|
+
directory: ProviderSettingsDirectory | undefined
|
|
1526
|
+
error: string | undefined
|
|
1527
|
+
authorizations: ProviderAuthorizationDirectory | undefined
|
|
1528
|
+
authorizationError: string | undefined
|
|
1529
|
+
onCredential(target: ProviderTargetView): void
|
|
1530
|
+
onConfigure(target: ProviderTargetView): void
|
|
1531
|
+
onUnset(target: ProviderTargetView): void
|
|
1532
|
+
onRemove(target: ProviderTargetView): void
|
|
1533
|
+
onLogin(target: ProviderTargetView, authorization: ProviderAuthorizationRow): void
|
|
1534
|
+
onLogout(target: ProviderTargetView, authorization: ProviderAuthorizationRow): void
|
|
1482
1535
|
onRetry(): void
|
|
1483
1536
|
onBack(): void
|
|
1484
1537
|
}): ReactElement {
|
|
@@ -1529,6 +1582,11 @@ function ProviderPanel({ directory, error, onCredential, onUnset, onRemove, onRe
|
|
|
1529
1582
|
}
|
|
1530
1583
|
const target = rows[cursor]
|
|
1531
1584
|
if (target === undefined) return
|
|
1585
|
+
if (key.tab) {
|
|
1586
|
+
if (target.settingsNs.length === 0) setActionError('this provider is not managed by Harness settings')
|
|
1587
|
+
else onConfigure(target)
|
|
1588
|
+
return
|
|
1589
|
+
}
|
|
1532
1590
|
if (input === 'd') {
|
|
1533
1591
|
const facts = target.credential
|
|
1534
1592
|
if (facts?.kind !== 'facts' || !facts.configured) {
|
|
@@ -1540,14 +1598,27 @@ function ProviderPanel({ directory, error, onCredential, onUnset, onRemove, onRe
|
|
|
1540
1598
|
}
|
|
1541
1599
|
return
|
|
1542
1600
|
}
|
|
1543
|
-
if (input === 'x') {
|
|
1601
|
+
if (input === 'x') {
|
|
1544
1602
|
if (!target.removable) {
|
|
1545
1603
|
setActionError('this provider profile is not removable')
|
|
1546
1604
|
} else {
|
|
1547
1605
|
onRemove(target)
|
|
1548
1606
|
}
|
|
1549
|
-
return
|
|
1550
|
-
}
|
|
1607
|
+
return
|
|
1608
|
+
}
|
|
1609
|
+
const authorization = authorizationForProvider(authorizations, target.provider)
|
|
1610
|
+
if (input === 'l' || input === 'L') {
|
|
1611
|
+
if (authorization === undefined) setActionError('this provider offers no interactive login flow')
|
|
1612
|
+
else if (authorization.inFlight) setActionError('a login attempt is already running for this provider')
|
|
1613
|
+
else onLogin(target, authorization)
|
|
1614
|
+
return
|
|
1615
|
+
}
|
|
1616
|
+
if (input === 'o' || input === 'O') {
|
|
1617
|
+
if (authorization === undefined || !authorization.record.configured) setActionError('this provider has no login record to remove')
|
|
1618
|
+
else if (!authorization.record.writable) setActionError('this login record is read-only')
|
|
1619
|
+
else onLogout(target, authorization)
|
|
1620
|
+
return
|
|
1621
|
+
}
|
|
1551
1622
|
if (key.return) {
|
|
1552
1623
|
if (target.settingsNs.length === 0) {
|
|
1553
1624
|
setActionError('this provider is not managed by Harness settings')
|
|
@@ -1575,11 +1646,19 @@ function ProviderPanel({ directory, error, onCredential, onUnset, onRemove, onRe
|
|
|
1575
1646
|
...(actionError === undefined
|
|
1576
1647
|
? []
|
|
1577
1648
|
: [createElement(Text, { key: 'action-error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${actionError}`, viewport.contentColumns))]),
|
|
1578
|
-
...(directory?.failures ?? []).map((failure, index) => createElement(
|
|
1649
|
+
...(directory?.failures ?? []).map((failure, index) => createElement(
|
|
1579
1650
|
Text,
|
|
1580
1651
|
{ key: `failure-${index}`, color: inkColor(getPalette().warn), wrap: 'truncate-end' },
|
|
1581
|
-
truncateColumns(` ${singleLineText(failure)}`, viewport.contentColumns),
|
|
1582
|
-
)),
|
|
1652
|
+
truncateColumns(` ${singleLineText(failure)}`, viewport.contentColumns),
|
|
1653
|
+
)),
|
|
1654
|
+
...(authorizationError === undefined
|
|
1655
|
+
? []
|
|
1656
|
+
: [createElement(Text, { key: 'authorization-error', color: inkColor(getPalette().warn), wrap: 'truncate-end' }, truncateColumns(` login status unavailable: ${singleLineText(authorizationError)}`, viewport.contentColumns))]),
|
|
1657
|
+
...(authorizations?.failures ?? []).map((failure, index) => createElement(
|
|
1658
|
+
Text,
|
|
1659
|
+
{ key: `authorization-failure-${index}`, color: inkColor(getPalette().warn), wrap: 'truncate-end' },
|
|
1660
|
+
truncateColumns(` ${singleLineText(failure)}`, viewport.contentColumns),
|
|
1661
|
+
)),
|
|
1583
1662
|
...(rows.length === 0
|
|
1584
1663
|
? [createElement(Text, { key: 'empty', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, ' no configurable providers')]
|
|
1585
1664
|
: []),
|
|
@@ -1595,9 +1674,13 @@ function ProviderPanel({ directory, error, onCredential, onUnset, onRemove, onRe
|
|
|
1595
1674
|
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1596
1675
|
...visibleStateRows,
|
|
1597
1676
|
...visible.map((row) => {
|
|
1598
|
-
const index = rows.indexOf(row)
|
|
1599
|
-
const identity = row.displayName === row.provider ? row.provider : `${row.displayName} (${row.provider})`
|
|
1600
|
-
const
|
|
1677
|
+
const index = rows.indexOf(row)
|
|
1678
|
+
const identity = row.displayName === row.provider ? row.provider : `${row.displayName} (${row.provider})`
|
|
1679
|
+
const authorization = authorizationForProvider(authorizations, row.provider)
|
|
1680
|
+
const manualKeyConfigured = row.credential?.kind === 'facts' && row.credential.configured
|
|
1681
|
+
const showAuthorization = !manualKeyConfigured || authorization?.record.configured === true || authorization?.inFlight === true
|
|
1682
|
+
const authLabel = showAuthorization ? ` · ${providerAuthorizationStatus(authorization)}` : ''
|
|
1683
|
+
const label = `${identity} · ${providerStateLabel(row)}${authLabel}${row.removable ? ' · custom' : ''}`
|
|
1601
1684
|
return createElement(
|
|
1602
1685
|
Text,
|
|
1603
1686
|
{ key: row.provider, color: index === cursor ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim), wrap: 'truncate-end' },
|
|
@@ -1605,7 +1688,111 @@ function ProviderPanel({ directory, error, onCredential, onUnset, onRemove, onRe
|
|
|
1605
1688
|
)
|
|
1606
1689
|
}),
|
|
1607
1690
|
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1608
|
-
createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns('↑↓ move · enter
|
|
1691
|
+
createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns('↑↓ move · enter key · l login · o logout · tab configure · d remove key · x remove provider · r retry · esc back', viewport.contentColumns)),
|
|
1692
|
+
)
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
/** Provider configuration editor: only explicit models are written to settings. */
|
|
1696
|
+
function ProviderConfigurationPanel({ target, catalog, save, done, back }: {
|
|
1697
|
+
target: ProviderTargetView
|
|
1698
|
+
catalog: readonly ModelRow[]
|
|
1699
|
+
save(target: ProviderTargetView, configuration: ProviderConfiguration): Promise<void>
|
|
1700
|
+
done(): void
|
|
1701
|
+
back(): void
|
|
1702
|
+
}): ReactElement {
|
|
1703
|
+
const stdout = useStdout().stdout
|
|
1704
|
+
const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
|
|
1705
|
+
const [baseURL, setBaseURL] = useState(target.configuration.baseURL ?? '')
|
|
1706
|
+
const [models, setModels] = useState<readonly ProviderConfiguration['models'][number][]>(target.configuration.models)
|
|
1707
|
+
const [cursor, setCursor] = useState(0)
|
|
1708
|
+
const [focus, setFocus] = useState<'url' | 'models' | 'context' | 'output'>('url')
|
|
1709
|
+
const [busy, setBusy] = useState(false)
|
|
1710
|
+
const [error, setError] = useState<string | undefined>(undefined)
|
|
1711
|
+
const choices = useMemo(() => {
|
|
1712
|
+
const known = catalog.filter(row => row.provider === target.provider)
|
|
1713
|
+
const ids = new Set(known.map(row => row.model))
|
|
1714
|
+
return [
|
|
1715
|
+
...known.map(row => ({ id: row.model, name: row.modelName })),
|
|
1716
|
+
...models.filter(model => !ids.has(model.id)).map(model => ({ id: model.id, name: model.name ?? model.id })),
|
|
1717
|
+
]
|
|
1718
|
+
}, [catalog, models, target.provider])
|
|
1719
|
+
const selected = choices[cursor]
|
|
1720
|
+
const selectedModel = selected === undefined ? undefined : models.find(model => model.id === selected.id)
|
|
1721
|
+
const updateSelected = (change: Partial<ProviderConfiguration['models'][number]>): void => {
|
|
1722
|
+
if (selected === undefined) return
|
|
1723
|
+
setModels(current => current.some(model => model.id === selected.id)
|
|
1724
|
+
? current.map(model => model.id === selected.id ? { ...model, ...change } : model)
|
|
1725
|
+
: [...current, { id: selected.id, name: selected.name, ...change }])
|
|
1726
|
+
}
|
|
1727
|
+
const submit = (): void => {
|
|
1728
|
+
if (busy) return
|
|
1729
|
+
setBusy(true)
|
|
1730
|
+
setError(undefined)
|
|
1731
|
+
void Promise.resolve().then(() => save(target, { ...(baseURL.trim() === '' ? {} : { baseURL }), models })).then(done, (reason: unknown) => {
|
|
1732
|
+
setBusy(false)
|
|
1733
|
+
setError(singleLineText(reason instanceof Error ? reason.message : String(reason)))
|
|
1734
|
+
})
|
|
1735
|
+
}
|
|
1736
|
+
useStableInput((input, key) => {
|
|
1737
|
+
if (busy) return
|
|
1738
|
+
if (key.escape || input === 'q') { back(); return }
|
|
1739
|
+
if (key.tab) {
|
|
1740
|
+
setFocus(current => current === 'url' ? 'models' : current === 'models' ? 'context' : current === 'context' ? 'output' : 'url')
|
|
1741
|
+
return
|
|
1742
|
+
}
|
|
1743
|
+
if (key.return) { submit(); return }
|
|
1744
|
+
if (focus === 'url') {
|
|
1745
|
+
if (key.backspace || key.delete) setBaseURL(current => deleteLastGrapheme(current))
|
|
1746
|
+
else if (!key.ctrl && !key.meta && input !== '') setBaseURL(current => current + stripPasteMarkers(input))
|
|
1747
|
+
return
|
|
1748
|
+
}
|
|
1749
|
+
if (key.upArrow && choices.length > 0) { setCursor(current => Math.max(0, current - 1)); return }
|
|
1750
|
+
if (key.downArrow && choices.length > 0) { setCursor(current => Math.min(choices.length - 1, current + 1)); return }
|
|
1751
|
+
if (focus === 'models' && input === ' ') {
|
|
1752
|
+
if (selectedModel === undefined) updateSelected({})
|
|
1753
|
+
else setModels(current => current.filter(model => model.id !== selectedModel.id))
|
|
1754
|
+
return
|
|
1755
|
+
}
|
|
1756
|
+
if ((focus === 'context' || focus === 'output') && selectedModel !== undefined) {
|
|
1757
|
+
const field = focus === 'context' ? 'contextWindow' : 'maxTokens'
|
|
1758
|
+
const current = String(selectedModel[field] ?? '')
|
|
1759
|
+
if (key.backspace || key.delete) {
|
|
1760
|
+
const next = current.slice(0, -1)
|
|
1761
|
+
updateSelected({ [field]: next === '' ? undefined : Number(next) })
|
|
1762
|
+
} else {
|
|
1763
|
+
// A pasted number arrives as one multi-character chunk; accept the
|
|
1764
|
+
// whole digit run instead of the single-character path only.
|
|
1765
|
+
const digits = stripPasteMarkers(input)
|
|
1766
|
+
if (/^[0-9]+$/u.test(digits)) {
|
|
1767
|
+
const next = `${current}${digits}`
|
|
1768
|
+
updateSelected({ [field]: Number(next) })
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
}, true)
|
|
1773
|
+
if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
|
|
1774
|
+
const stateRows = error === undefined ? [] : [createElement(Text, { key: 'error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${error}`, viewport.contentColumns))]
|
|
1775
|
+
const rowBudget = Math.max(0, viewport.bodyRows - stateRows.length - 1)
|
|
1776
|
+
const first = selectionWindow(cursor, choices.length, rowBudget)
|
|
1777
|
+
const visible = choices.slice(first, first + rowBudget)
|
|
1778
|
+
return createElement(
|
|
1779
|
+
Box,
|
|
1780
|
+
{ flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().brand) },
|
|
1781
|
+
createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model - ${target.displayName} configuration`, viewport.contentColumns)),
|
|
1782
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1783
|
+
createElement(Text, { color: focus === 'url' ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns(` ${focus === 'url' ? '>' : ' '} endpoint: ${baseURL === '' ? '(adapter default)' : baseURL}`, viewport.contentColumns)),
|
|
1784
|
+
...stateRows,
|
|
1785
|
+
...visible.map((choice, index) => {
|
|
1786
|
+
const absolute = first + index
|
|
1787
|
+
const model = models.find(item => item.id === choice.id)
|
|
1788
|
+
const selectedMark = model === undefined ? '[ ]' : '[x]'
|
|
1789
|
+
const context = model?.contextWindow === undefined ? '-' : String(model.contextWindow)
|
|
1790
|
+
const output = model?.maxTokens === undefined ? '-' : String(model.maxTokens)
|
|
1791
|
+
const active = absolute === cursor && focus !== 'url'
|
|
1792
|
+
return createElement(Text, { key: choice.id, color: active ? inkColor(getPalette().brandBright) : model === undefined ? inkColor(getPalette().dim) : inkColor(getPalette().success), wrap: 'truncate-end' }, truncateColumns(`${active ? '>' : ' '} ${selectedMark} ${choice.name} in:${context} out:${output}`, viewport.contentColumns))
|
|
1793
|
+
}),
|
|
1794
|
+
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1795
|
+
createElement(Text, { color: inkColor(getPalette().dim), wrap: 'truncate-end' }, truncateColumns('tab endpoint/models/input/output - space select - arrows model - digits set window - enter save - esc back', viewport.contentColumns)),
|
|
1609
1796
|
)
|
|
1610
1797
|
}
|
|
1611
1798
|
|
|
@@ -1657,7 +1844,7 @@ function ProviderCredentialPanel({ target, save, done, back }: {
|
|
|
1657
1844
|
return
|
|
1658
1845
|
}
|
|
1659
1846
|
if (key.ctrl || key.meta || input.length === 0) return
|
|
1660
|
-
const next = draft + input
|
|
1847
|
+
const next = draft + stripPasteMarkers(input)
|
|
1661
1848
|
if (next.length > 4096) {
|
|
1662
1849
|
setError('API key input is too long')
|
|
1663
1850
|
return
|
|
@@ -1780,8 +1967,8 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
|
|
|
1780
1967
|
)
|
|
1781
1968
|
const content: ReactElement[] = [
|
|
1782
1969
|
createElement(Text, { key: 'keys-title', bold: true, wrap: 'truncate-end' }, ' keys'),
|
|
1783
|
-
createElement(Text, { key: 'key-submit', dimColor: true, wrap: 'truncate-end' }, ' enter submit ·
|
|
1784
|
-
createElement(Text, { key: 'key-mentions', dimColor: true, wrap: 'truncate-end' }, '
|
|
1970
|
+
createElement(Text, { key: 'key-submit', dimColor: true, wrap: 'truncate-end' }, ' enter submit · up/down history · tab complete'),
|
|
1971
|
+
createElement(Text, { key: 'key-mentions', dimColor: true, wrap: 'truncate-end' }, ' @ mentions workspace files and sessions'),
|
|
1785
1972
|
createElement(Text, { key: 'key-inspector', dimColor: true, wrap: 'truncate-end' }, ' ctrl+o history details · ctrl+r thinking · shift+tab permission preset'),
|
|
1786
1973
|
createElement(Text, { key: 'key-cancel', dimColor: true, wrap: 'truncate-end' }, ' esc interrupt the running turn · ctrl+c cancel / clear / quit · ctrl+d exit'),
|
|
1787
1974
|
createElement(Text, { key: 'key-queue', dimColor: true, wrap: 'truncate-end' }, ' delete on the empty composer cancels the newest queued message'),
|
|
@@ -1795,25 +1982,12 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
|
|
|
1795
1982
|
{ key: 'commands-error', color: inkColor(getPalette().error), wrap: 'truncate-end' },
|
|
1796
1983
|
truncateColumns(` command catalog unavailable: ${singleLineText(commandError)}`, viewport.contentColumns),
|
|
1797
1984
|
)]),
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
createElement(Box, { key: 'local-resume' }, row('/resume', 'browse or switch root sessions (/resume [id|prefix])')),
|
|
1805
|
-
createElement(Box, { key: 'local-plugin' }, row('/plugin', 'inspect the live plugin composition')),
|
|
1806
|
-
createElement(Box, { key: 'local-statusline' }, row('/statusline', 'customize the status line items')),
|
|
1807
|
-
createElement(Box, { key: 'local-theme' }, row('/theme', 'switch the color theme')),
|
|
1808
|
-
createElement(Box, { key: 'local-history' }, row('/history', 'search and recall past prompts')),
|
|
1809
|
-
createElement(Box, { key: 'local-agents' }, row('/agents', 'inspect subagent sessions of this conversation')),
|
|
1810
|
-
createElement(Box, { key: 'local-subagent' }, row('/subagent', 'choose the model delegated subagents run on')),
|
|
1811
|
-
createElement(Box, { key: 'local-delete' }, row('/delete', 'delete a session and its subagent threads')),
|
|
1812
|
-
createElement(Box, { key: 'local-clear' }, row('/clear', 'clear the screen')),
|
|
1813
|
-
createElement(Box, { key: 'local-export' }, row('/export', 'export the transcript to markdown (/export [path])')),
|
|
1814
|
-
createElement(Box, { key: 'local-title' }, row('/title', 'rename this session (/title <text>)')),
|
|
1815
|
-
createElement(Box, { key: 'local-quit' }, row('/quit', 'exit')),
|
|
1816
|
-
...descriptors.map(descriptor => createElement(
|
|
1985
|
+
...LOCAL_COMMANDS.map(command => createElement(
|
|
1986
|
+
Box,
|
|
1987
|
+
{ key: `local-${command.label.slice(1)}` },
|
|
1988
|
+
row(command.label, command.description),
|
|
1989
|
+
)),
|
|
1990
|
+
...descriptors.filter(descriptor => !LOCAL_COMMAND_NAMES.has(descriptor.name)).map(descriptor => createElement(
|
|
1817
1991
|
Text,
|
|
1818
1992
|
{ key: `command-${descriptor.name}`, dimColor: true, wrap: 'truncate-end' },
|
|
1819
1993
|
` ${padColumns(`/${descriptor.name}`, nameWidth)}${dim(truncateColumns(displayText(descriptor.description), descBudget))}`,
|
|
@@ -1880,21 +2054,74 @@ function verboseLine(text: string, columns: number): string {
|
|
|
1880
2054
|
return truncateColumns(displayText(text).replace(/\n/gu, ' ↵ ').replace(/\t/gu, ' '), Math.max(1, columns))
|
|
1881
2055
|
}
|
|
1882
2056
|
|
|
1883
|
-
/**
|
|
1884
|
-
|
|
2057
|
+
/**
|
|
2058
|
+
* Keys Ink 5's parser cannot express at the useInput boundary: Home/End
|
|
2059
|
+
* arrive with `input === ''` and no flag, and Backspace vs Delete both
|
|
2060
|
+
* collapse onto `key.delete`. The composer patches `stdin.read` — the one
|
|
2061
|
+
* choke point every Ink input chunk already passes through — and annotates
|
|
2062
|
+
* the exact sequences the editor must own; Ink's own view of the same chunk
|
|
2063
|
+
* is a no-op for every one of them.
|
|
2064
|
+
*/
|
|
2065
|
+
type RawKeyAnnotation =
|
|
2066
|
+
| 'home'
|
|
2067
|
+
| 'end'
|
|
2068
|
+
| 'delete-backward'
|
|
2069
|
+
| 'delete-word-backward'
|
|
2070
|
+
| 'delete-forward'
|
|
2071
|
+
| 'delete-word-forward'
|
|
2072
|
+
| undefined
|
|
2073
|
+
|
|
2074
|
+
/** Identify one whole-chunk key sequence Ink drops or blurs. */
|
|
2075
|
+
function annotateRawKey(chunk: string): RawKeyAnnotation {
|
|
2076
|
+
switch (chunk) {
|
|
2077
|
+
case '':
|
|
2078
|
+
return 'delete-backward'
|
|
2079
|
+
case '':
|
|
2080
|
+
case '':
|
|
2081
|
+
return 'delete-word-backward'
|
|
2082
|
+
case '[3~':
|
|
2083
|
+
case '[3;2~':
|
|
2084
|
+
return 'delete-forward'
|
|
2085
|
+
case '[3;3~':
|
|
2086
|
+
case '[3;5~':
|
|
2087
|
+
return 'delete-word-forward'
|
|
2088
|
+
case '[H':
|
|
2089
|
+
case '[1~':
|
|
2090
|
+
case '[7~':
|
|
2091
|
+
case 'OH':
|
|
2092
|
+
return 'home'
|
|
2093
|
+
case '[F':
|
|
2094
|
+
case '[4~':
|
|
2095
|
+
case '[8~':
|
|
2096
|
+
case 'OF':
|
|
2097
|
+
return 'end'
|
|
2098
|
+
default:
|
|
2099
|
+
return undefined
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
/**
|
|
2104
|
+
* One-row editor window keeping the logical cursor visible in long drafts.
|
|
2105
|
+
* The caret and its surroundings slice at grapheme boundaries: splitting a
|
|
2106
|
+
* star-plane surrogate pair would render an isolated half under the block
|
|
2107
|
+
* caret with a width the terminal never draws.
|
|
2108
|
+
*/
|
|
2109
|
+
export function editorWindow(value: string, cursor: number, columns: number): { before: string; caret: string; after: string } {
|
|
1885
2110
|
const width = Math.max(1, columns)
|
|
1886
2111
|
const normalize = (text: string): string => displayText(text).replace(/\n/gu, '↵').replace(/\t/gu, ' ')
|
|
1887
|
-
const
|
|
1888
|
-
const
|
|
2112
|
+
const site = clampCursor(value, cursor)
|
|
2113
|
+
const caretSpan = splitGraphemes(value).find(span => span.start === site)
|
|
2114
|
+
const caret = caretSpan === undefined ? ' ' : normalize(caretSpan.text)
|
|
2115
|
+
const rest = value.slice(caretSpan === undefined ? site : caretSpan.end)
|
|
1889
2116
|
const remaining = Math.max(0, width - visibleColumns(caret))
|
|
1890
|
-
const afterBudget = Math.min(Math.floor(remaining / 3), visibleColumns(normalize(
|
|
2117
|
+
const afterBudget = Math.min(Math.floor(remaining / 3), visibleColumns(normalize(rest)))
|
|
1891
2118
|
const beforeBudget = Math.max(0, remaining - afterBudget)
|
|
1892
2119
|
const before = beforeBudget === 0
|
|
1893
2120
|
? ''
|
|
1894
|
-
: displayTail(normalize(value.slice(0,
|
|
2121
|
+
: displayTail(normalize(value.slice(0, site)), beforeBudget, 1).text
|
|
1895
2122
|
const after = afterBudget === 0
|
|
1896
2123
|
? ''
|
|
1897
|
-
: truncateColumns(normalize(
|
|
2124
|
+
: truncateColumns(normalize(rest), afterBudget)
|
|
1898
2125
|
return { before, caret, after }
|
|
1899
2126
|
}
|
|
1900
2127
|
|
|
@@ -2112,7 +2339,7 @@ interface CompletionCandidate {
|
|
|
2112
2339
|
/** Human-readable description shown beside the label. */
|
|
2113
2340
|
description: string
|
|
2114
2341
|
/** Candidate origin; skills land the same literal text but route through the prompt. */
|
|
2115
|
-
origin: 'command' | 'skill' | 'mention'
|
|
2342
|
+
origin: 'command' | 'skill' | 'mention'
|
|
2116
2343
|
}
|
|
2117
2344
|
|
|
2118
2345
|
/**
|
|
@@ -2133,32 +2360,12 @@ export function completionCandidates(
|
|
|
2133
2360
|
): readonly CompletionCandidate[] {
|
|
2134
2361
|
if (!value.startsWith('/')) return []
|
|
2135
2362
|
const prefix = value.slice(1).split(' ')[0] ?? ''
|
|
2136
|
-
const local: CompletionCandidate[] =
|
|
2137
|
-
{ label: '/help', description: 'show commands', origin: 'command' },
|
|
2138
|
-
{ label: '/model', description: 'switch the model', origin: 'command' },
|
|
2139
|
-
{ label: '/effort', description: 'adjust reasoning effort for the current model', origin: 'command' },
|
|
2140
|
-
{ label: '/mode', description: 'select the agent preset', origin: 'command' },
|
|
2141
|
-
{ label: '/permission', description: 'inspect or select the permission preset', origin: 'command' },
|
|
2142
|
-
{ label: '/new', description: 'start a fresh session', origin: 'command' },
|
|
2143
|
-
{ label: '/resume', description: 'browse or switch sessions', origin: 'command' },
|
|
2144
|
-
{ label: '/plugin', description: 'inspect the plugin composition', origin: 'command' },
|
|
2145
|
-
{ label: '/statusline', description: 'customize the status line', origin: 'command' },
|
|
2146
|
-
{ label: '/theme', description: 'switch the color theme', origin: 'command' },
|
|
2147
|
-
{ label: '/history', description: 'search and recall past prompts', origin: 'command' },
|
|
2148
|
-
{ label: '/agents', description: 'inspect subagent sessions of this conversation', origin: 'command' },
|
|
2149
|
-
{ label: '/subagent', description: 'choose the model delegated subagents run on', origin: 'command' },
|
|
2150
|
-
{ label: '/delete', description: 'delete a session and its subagent threads', origin: 'command' },
|
|
2151
|
-
{ label: '/clear', description: 'clear the screen', origin: 'command' },
|
|
2152
|
-
{ label: '/export', description: 'export the transcript to markdown', origin: 'command' },
|
|
2153
|
-
{ label: '/title', description: 'rename this session', origin: 'command' },
|
|
2154
|
-
{ label: '/quit', description: 'exit', origin: 'command' },
|
|
2155
|
-
]
|
|
2363
|
+
const local: CompletionCandidate[] = LOCAL_COMMANDS.map(command => ({ ...command, origin: 'command' }))
|
|
2156
2364
|
// Local commands shadow registry names (e.g. the TUI-local /permission works
|
|
2157
2365
|
// before any session exists, while the registry child needs one), so
|
|
2158
2366
|
// collisions cannot render two rows with the same key.
|
|
2159
|
-
const
|
|
2160
|
-
|
|
2161
|
-
.filter(descriptor => !localNames.has(descriptor.name))
|
|
2367
|
+
const registry = descriptors
|
|
2368
|
+
.filter(descriptor => !LOCAL_COMMAND_NAMES.has(descriptor.name))
|
|
2162
2369
|
.map((descriptor): CompletionCandidate => ({
|
|
2163
2370
|
label: `/${descriptor.name}`,
|
|
2164
2371
|
description: descriptor.description,
|
|
@@ -2189,7 +2396,7 @@ export function completionCandidates(
|
|
|
2189
2396
|
|
|
2190
2397
|
/**
|
|
2191
2398
|
* The completion menu, rendered inside the composer's subtree directly above
|
|
2192
|
-
* the
|
|
2399
|
+
* the composer band — attached the way Claude-Code anchors its dropdown. Opening
|
|
2193
2400
|
* it grows the stack downward: the composer stays the last element on screen
|
|
2194
2401
|
* and everything above (the flushed static transcript, the status line) never
|
|
2195
2402
|
* moves. Props-only (no lifted state): the menu is a pure view of the input
|
|
@@ -2209,7 +2416,11 @@ function CompletionMenu({ active, mention, index, rows }: {
|
|
|
2209
2416
|
const terminalRows = stdout?.rows ?? 30
|
|
2210
2417
|
if (!active) return undefined
|
|
2211
2418
|
const contentColumns = Math.max(1, columns - 4)
|
|
2212
|
-
|
|
2419
|
+
// File paths are the decision-making data in an @ menu. Give mentions the
|
|
2420
|
+
// full available line and sacrifice their repetitive kind label first.
|
|
2421
|
+
const nameWidth = mention
|
|
2422
|
+
? Math.max(1, contentColumns - 2)
|
|
2423
|
+
: Math.min(18, Math.max(1, contentColumns - 2), Math.max(0, ...rows.map(row => visibleColumns(row.label))) + 2)
|
|
2213
2424
|
const descBudget = Math.max(0, contentColumns - nameWidth - 2)
|
|
2214
2425
|
const showFooter = terminalRows >= 12
|
|
2215
2426
|
const spacious = terminalRows >= 14
|
|
@@ -2244,20 +2455,25 @@ function CompletionMenu({ active, mention, index, rows }: {
|
|
|
2244
2455
|
)
|
|
2245
2456
|
}
|
|
2246
2457
|
|
|
2247
|
-
|
|
2248
|
-
|
|
2458
|
+
interface DraftImage extends ImagePathInspection {
|
|
2459
|
+
/** Visible draft token; deleting it also detaches the hidden path. */
|
|
2460
|
+
readonly marker: string
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
/**
|
|
2464
|
+
* The prompt box: TUI-local slash commands handled locally, other lines
|
|
2249
2465
|
* dispatched; input editing keeps a cursor with history and completion.
|
|
2250
2466
|
* While a modal (approval / question / model panel) owns the keys, the
|
|
2251
2467
|
* box passes every key through untouched.
|
|
2252
2468
|
*/
|
|
2253
|
-
function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openEffort, openHelp, openMode, openPermission, openResume, openPlugin, openStatusline, openTheme, openHistory, openAgents, openSubagent, openDelete, deleteConfirm, confirmDelete, cancelDelete, createSession, cancelSessionSwitch, notify, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, cyclePermission, exportTranscript, renameTitle, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed, waveTier, waveStyle }: {
|
|
2469
|
+
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, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, inspectImages, prepareImages, cyclePermission, exportTranscript, renameTitle, copyLastResponse, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed, waveTier, waveStyle, maxRows, onEditorRows }: {
|
|
2254
2470
|
active: boolean
|
|
2255
2471
|
frozen: boolean
|
|
2256
2472
|
busy: boolean
|
|
2257
2473
|
descriptors: readonly CommandDescriptor[]
|
|
2258
2474
|
skills: readonly SkillRow[]
|
|
2259
|
-
dispatch(text: string): void
|
|
2260
|
-
steer(text: string): void
|
|
2475
|
+
dispatch(text: string, images?: readonly ImageBlock[]): void
|
|
2476
|
+
steer(text: string, images?: readonly ImageBlock[]): void
|
|
2261
2477
|
interrupt(): boolean
|
|
2262
2478
|
quit(): void
|
|
2263
2479
|
openModel(): void
|
|
@@ -2267,6 +2483,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2267
2483
|
openPermission(): void
|
|
2268
2484
|
openResume(): void
|
|
2269
2485
|
openPlugin(query?: string): void
|
|
2486
|
+
openJobs(): void
|
|
2270
2487
|
openStatusline(): void
|
|
2271
2488
|
openTheme(): void
|
|
2272
2489
|
openHistory(): void
|
|
@@ -2274,8 +2491,12 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2274
2491
|
openAgents(): void
|
|
2275
2492
|
/** Open the /subagent model panel. */
|
|
2276
2493
|
openSubagent(): void
|
|
2494
|
+
/** Open the /todos subpage (full todo list in one bounded panel). */
|
|
2495
|
+
openTodos(): void
|
|
2277
2496
|
/** Open the /resume picker in delete mode, optionally pre-armed on one id. */
|
|
2278
2497
|
openDelete(id?: string): void
|
|
2498
|
+
openDiff(argument: string): void
|
|
2499
|
+
reviewChanges(argument: string): void
|
|
2279
2500
|
/** The row id awaiting y/n in this box, when a deletion is pending. */
|
|
2280
2501
|
deleteConfirm?: string
|
|
2281
2502
|
/** Confirm the pending deletion (y in the box). */
|
|
@@ -2283,6 +2504,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2283
2504
|
/** Cancel the pending deletion (any other key in the box). */
|
|
2284
2505
|
cancelDelete(): void
|
|
2285
2506
|
createSession(mode?: string): void
|
|
2507
|
+
forkSession(argument: string): void
|
|
2286
2508
|
cancelSessionSwitch(): boolean
|
|
2287
2509
|
notify(text: string, tone?: NoticeTone): void
|
|
2288
2510
|
hasNotice: boolean
|
|
@@ -2291,10 +2513,13 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2291
2513
|
openVerbose(): void
|
|
2292
2514
|
clearView(): void
|
|
2293
2515
|
refresh(): void
|
|
2294
|
-
loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
|
|
2516
|
+
loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
|
|
2517
|
+
inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
|
|
2518
|
+
prepareImages(paths: readonly string[]): Promise<readonly ImageBlock[]>
|
|
2295
2519
|
cyclePermission(): string
|
|
2296
2520
|
exportTranscript(argument: string): Promise<void>
|
|
2297
2521
|
renameTitle(argument: string): string
|
|
2522
|
+
copyLastResponse(): Promise<string>
|
|
2298
2523
|
/** Newest-first recall space (persistent + in-session, deduped). */
|
|
2299
2524
|
recallSpace: readonly string[]
|
|
2300
2525
|
/** Record one in-session submission (deduped, local only). */
|
|
@@ -2317,29 +2542,91 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2317
2542
|
waveTier: DeepseekWaveTier | null
|
|
2318
2543
|
/** The ignition style running, if any: Wave / Aurora / Pulse. */
|
|
2319
2544
|
waveStyle: DeepseekWaveStyle | null
|
|
2545
|
+
/** Maximum physical editor rows the composer may occupy (see composerMaxRows). */
|
|
2546
|
+
maxRows: number
|
|
2547
|
+
/** Reports the editor's current physical row count so the live budget stays exact. */
|
|
2548
|
+
onEditorRows(rows: number): void
|
|
2320
2549
|
}): ReactElement {
|
|
2321
2550
|
const columns = useStdout().stdout?.columns ?? 80
|
|
2322
|
-
const
|
|
2323
|
-
const [
|
|
2551
|
+
const stdin = useStdin().stdin
|
|
2552
|
+
const [value, setValue] = useState('')
|
|
2553
|
+
const [cursor, setCursor] = useState(0)
|
|
2554
|
+
const valueRef = useRef(value)
|
|
2555
|
+
const cursorRef = useRef(cursor)
|
|
2556
|
+
valueRef.current = value
|
|
2557
|
+
cursorRef.current = cursor
|
|
2558
|
+
const [draftImages, setDraftImages] = useState<readonly DraftImage[]>([])
|
|
2559
|
+
const draftImagesRef = useRef(draftImages)
|
|
2560
|
+
draftImagesRef.current = draftImages
|
|
2561
|
+
const [preparingImages, setPreparingImages] = useState(false)
|
|
2562
|
+
// Codex textarea editing state: a single-entry kill buffer, the vertical
|
|
2563
|
+
// move's preferred display column, the editor's scroll window, and the
|
|
2564
|
+
// bracketed-paste marker state. All of it is editor-local; nothing here
|
|
2565
|
+
// ever reaches the App.
|
|
2566
|
+
const killRef = useRef('')
|
|
2567
|
+
const preferredColumnRef = useRef<number | null>(null)
|
|
2568
|
+
const editorScrollRef = useRef(0)
|
|
2569
|
+
const pasteBracketRef = useRef(false)
|
|
2570
|
+
/** Cancels the pending lost-paste safety timer (undefined when disarmed). */
|
|
2571
|
+
const pasteBracketCancelRef = useRef<(() => void) | undefined>(undefined)
|
|
2572
|
+
/** Annotation of the stdin chunk Ink is about to deliver to useInput. */
|
|
2573
|
+
const rawAnnotation = useRef<RawKeyAnnotation>(undefined)
|
|
2324
2574
|
// Codex shell-style recall: the navigation cursor, the saved draft restored
|
|
2325
2575
|
// on Down past the newest entry, and the boundary-gate anchor.
|
|
2326
|
-
const recall = useRef<RecallState>(
|
|
2576
|
+
const recall = useRef<RecallState>(beginRecall([], ''))
|
|
2327
2577
|
|
|
2328
|
-
// A /history panel acceptance lands as a fill: place the text at
|
|
2329
|
-
// the composer and resume recall from that entry.
|
|
2330
|
-
useEffect(() => {
|
|
2331
|
-
if (historyFill === undefined) return
|
|
2332
|
-
|
|
2333
|
-
|
|
2578
|
+
// A /history panel acceptance lands as a fill: place the sanitized text at
|
|
2579
|
+
// the end of the composer and resume recall from that entry.
|
|
2580
|
+
useEffect(() => {
|
|
2581
|
+
if (historyFill === undefined) return
|
|
2582
|
+
const safe = sanitizeDraftText(historyFill.text)
|
|
2583
|
+
draftImagesRef.current = []
|
|
2584
|
+
setDraftImages([])
|
|
2585
|
+
setValue(safe)
|
|
2586
|
+
setCursor(safe.length)
|
|
2587
|
+
preferredColumnRef.current = null
|
|
2334
2588
|
setDismissedMenuValue(undefined)
|
|
2335
2589
|
recall.current = {
|
|
2336
2590
|
entries: recallSpace,
|
|
2337
2591
|
index: historyFill.index,
|
|
2338
|
-
savedDraft:
|
|
2339
|
-
lastRecalled:
|
|
2592
|
+
savedDraft: safe,
|
|
2593
|
+
lastRecalled: safe,
|
|
2340
2594
|
}
|
|
2341
2595
|
historyConsumed()
|
|
2342
|
-
}, [historyFill, recallSpace, historyConsumed])
|
|
2596
|
+
}, [historyFill, recallSpace, historyConsumed])
|
|
2597
|
+
|
|
2598
|
+
useEffect(() => {
|
|
2599
|
+
setDraftImages((current) => {
|
|
2600
|
+
const next = current.filter(image => value.includes(image.marker))
|
|
2601
|
+
draftImagesRef.current = next
|
|
2602
|
+
return next.length === current.length ? current : next
|
|
2603
|
+
})
|
|
2604
|
+
}, [value])
|
|
2605
|
+
|
|
2606
|
+
// Home/End and the Backspace-vs-Delete family never survive Ink's parser
|
|
2607
|
+
// as distinct keys, and kitty CSI-u forms parse as unnamed junk Ink would
|
|
2608
|
+
// insert as draft text.
|
|
2609
|
+
// Patch stdin.read — the single choke point Ink's input loop pulls every
|
|
2610
|
+
// chunk through — to first rewrite decodable CSI-u sequences to their
|
|
2611
|
+
// legacy bytes, then annotate the resulting chunk before Ink emits the
|
|
2612
|
+
// matching 'input' event, so the useInput handler below reads the
|
|
2613
|
+
// annotation for exactly the chunk it is processing. Ink receives and
|
|
2614
|
+
// parses the normalized string; every other byte passes through untouched.
|
|
2615
|
+
useEffect(() => {
|
|
2616
|
+
if (stdin === undefined) return
|
|
2617
|
+
const originalRead = stdin.read.bind(stdin)
|
|
2618
|
+
const patchedRead = function patchedRead(this: typeof stdin, ...args: Parameters<typeof originalRead>) {
|
|
2619
|
+
const chunk = originalRead(...args)
|
|
2620
|
+
if (chunk === null) return chunk
|
|
2621
|
+
const normalized = normalizeKeyboardChunk(typeof chunk === 'string' ? chunk : String(chunk))
|
|
2622
|
+
rawAnnotation.current = annotateRawKey(normalized)
|
|
2623
|
+
return normalized
|
|
2624
|
+
} as typeof stdin.read
|
|
2625
|
+
stdin.read = patchedRead
|
|
2626
|
+
return () => {
|
|
2627
|
+
stdin.read = originalRead as typeof stdin.read
|
|
2628
|
+
}
|
|
2629
|
+
}, [stdin])
|
|
2343
2630
|
|
|
2344
2631
|
// Keep the navigation's recall space fresh while browsing state survives
|
|
2345
2632
|
// (new local submissions extend the space; the index stays valid unless
|
|
@@ -2362,37 +2649,67 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2362
2649
|
const mentionToken = tokenMatch === null
|
|
2363
2650
|
? undefined
|
|
2364
2651
|
: { start: beforeCursor.length - lastLine.length + (tokenMatch.index ?? 0) + (tokenMatch[1]?.length ?? 0), query: tokenMatch[2] ?? '' }
|
|
2365
|
-
const mentionActive = mentionToken !== undefined
|
|
2366
|
-
const [mentionRows, setMentionRows] = useState<readonly MentionCandidate[]>([])
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
)
|
|
2392
|
-
return
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2652
|
+
const mentionActive = mentionToken !== undefined
|
|
2653
|
+
const [mentionRows, setMentionRows] = useState<readonly MentionCandidate[]>([])
|
|
2654
|
+
|
|
2655
|
+
const sameImagePath = (left: string, right: string): boolean => (
|
|
2656
|
+
process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right
|
|
2657
|
+
)
|
|
2658
|
+
|
|
2659
|
+
const uniqueImageMarker = (name: string, source: 'mention' | 'drop', reserved: readonly string[] = []): string => {
|
|
2660
|
+
const safeName = singleLineText(sanitizeDraftText(name))
|
|
2661
|
+
const base = source === 'mention' ? `@${safeName}` : `[image: ${safeName}]`
|
|
2662
|
+
let marker = base
|
|
2663
|
+
let suffix = 2
|
|
2664
|
+
while (valueRef.current.includes(marker) || draftImagesRef.current.some(image => image.marker === marker) || reserved.includes(marker)) {
|
|
2665
|
+
marker = source === 'mention' ? `@${safeName} (${suffix})` : `[image: ${safeName} ${suffix}]`
|
|
2666
|
+
suffix += 1
|
|
2667
|
+
}
|
|
2668
|
+
return marker
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
const registerDraftImage = (inspection: ImagePathInspection, marker: string): boolean => {
|
|
2672
|
+
if (draftImagesRef.current.some(image => sameImagePath(image.path, inspection.path))) {
|
|
2673
|
+
notify(`${inspection.name} is already attached`, 'warning')
|
|
2674
|
+
return false
|
|
2675
|
+
}
|
|
2676
|
+
const next = [...draftImagesRef.current, { ...inspection, marker }]
|
|
2677
|
+
draftImagesRef.current = next
|
|
2678
|
+
setDraftImages(next)
|
|
2679
|
+
return true
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
const insertDroppedImages = (paths: readonly string[]): void => {
|
|
2683
|
+
notify(`checking ${paths.length} image${paths.length === 1 ? '' : 's'}…`)
|
|
2684
|
+
void inspectImages(paths).then((inspected) => {
|
|
2685
|
+
const additions: DraftImage[] = []
|
|
2686
|
+
const markers: string[] = []
|
|
2687
|
+
for (const inspection of inspected) {
|
|
2688
|
+
if ([...draftImagesRef.current, ...additions].some(image => sameImagePath(image.path, inspection.path))) continue
|
|
2689
|
+
const marker = uniqueImageMarker(inspection.name, 'drop', markers)
|
|
2690
|
+
additions.push({ ...inspection, marker })
|
|
2691
|
+
markers.push(marker)
|
|
2692
|
+
}
|
|
2693
|
+
if (additions.length === 0) {
|
|
2694
|
+
notify('those images are already attached', 'warning')
|
|
2695
|
+
return
|
|
2696
|
+
}
|
|
2697
|
+
const at = cursorRef.current
|
|
2698
|
+
const current = valueRef.current
|
|
2699
|
+
const insertion = `${at > 0 && !/\s$/u.test(current.slice(0, at)) ? ' ' : ''}${markers.join(' ')}${current.slice(at) === '' ? '' : ' '}`
|
|
2700
|
+
const next = current.slice(0, at) + insertion + current.slice(at)
|
|
2701
|
+
valueRef.current = next
|
|
2702
|
+
cursorRef.current = at + insertion.length
|
|
2703
|
+
setValue(next)
|
|
2704
|
+
setCursor(cursorRef.current)
|
|
2705
|
+
const nextImages = [...draftImagesRef.current, ...additions]
|
|
2706
|
+
draftImagesRef.current = nextImages
|
|
2707
|
+
setDraftImages(nextImages)
|
|
2708
|
+
notify(`${additions.length} image${additions.length === 1 ? '' : 's'} ready for the next message`)
|
|
2709
|
+
}, (reason: unknown) => {
|
|
2710
|
+
notify(`image attachment failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
|
|
2711
|
+
})
|
|
2712
|
+
}
|
|
2396
2713
|
|
|
2397
2714
|
useEffect(() => {
|
|
2398
2715
|
if (!active || !mentionActive) {
|
|
@@ -2413,7 +2730,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2413
2730
|
// Codex routes keys to the topmost surface first. Completion therefore
|
|
2414
2731
|
// remains available while a turn runs, and Esc dismisses it before the
|
|
2415
2732
|
// same key is allowed to interrupt the turn.
|
|
2416
|
-
const menuActive = (slashActive || mentionActive
|
|
2733
|
+
const menuActive = (slashActive || mentionActive) && dismissedMenuValue !== value
|
|
2417
2734
|
const menuRows: readonly CompletionCandidate[] = mentionActive
|
|
2418
2735
|
? mentionRows.map(row => ({
|
|
2419
2736
|
label: row.label.startsWith('@')
|
|
@@ -2422,20 +2739,49 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2422
2739
|
description: row.description,
|
|
2423
2740
|
origin: 'mention',
|
|
2424
2741
|
}))
|
|
2425
|
-
:
|
|
2426
|
-
? pathRows.map(row => ({
|
|
2427
|
-
label: row.label,
|
|
2428
|
-
description: row.description,
|
|
2429
|
-
origin: 'path',
|
|
2430
|
-
}))
|
|
2431
|
-
: candidates
|
|
2742
|
+
: candidates
|
|
2432
2743
|
|
|
2433
2744
|
/** Accept the highlighted completion-menu candidate into the draft. */
|
|
2434
2745
|
const acceptMenuCandidate = (): void => {
|
|
2435
2746
|
if (mentionActive && mentionToken !== undefined) {
|
|
2436
|
-
const row = mentionRows[completionIndex % mentionRows.length]
|
|
2437
|
-
if (row !== undefined) {
|
|
2438
|
-
|
|
2747
|
+
const row = mentionRows[completionIndex % mentionRows.length]
|
|
2748
|
+
if (row !== undefined) {
|
|
2749
|
+
if (row.kind === 'file' && row.path !== undefined && looksLikeImagePath(row.path)) {
|
|
2750
|
+
const tokenText = value.slice(mentionToken.start, cursor)
|
|
2751
|
+
const start = mentionToken.start
|
|
2752
|
+
notify(`checking image ${basename(row.path)}…`)
|
|
2753
|
+
void inspectImages([row.path]).then((inspected) => {
|
|
2754
|
+
const inspection = inspected[0]
|
|
2755
|
+
if (inspection === undefined) return
|
|
2756
|
+
const current = valueRef.current
|
|
2757
|
+
if (current.slice(start, start + tokenText.length) !== tokenText) return
|
|
2758
|
+
if (draftImagesRef.current.some(image => sameImagePath(image.path, inspection.path))) {
|
|
2759
|
+
const next = current.slice(0, start) + current.slice(start + tokenText.length)
|
|
2760
|
+
valueRef.current = next
|
|
2761
|
+
cursorRef.current = start
|
|
2762
|
+
setValue(next)
|
|
2763
|
+
setCursor(start)
|
|
2764
|
+
setDismissedMenuValue(next)
|
|
2765
|
+
notify(`${inspection.name} is already attached`, 'warning')
|
|
2766
|
+
return
|
|
2767
|
+
}
|
|
2768
|
+
const marker = uniqueImageMarker(inspection.name, 'mention')
|
|
2769
|
+
const next = current.slice(0, start) + marker + current.slice(start + tokenText.length)
|
|
2770
|
+
valueRef.current = next
|
|
2771
|
+
cursorRef.current = start + marker.length
|
|
2772
|
+
setValue(next)
|
|
2773
|
+
setCursor(cursorRef.current)
|
|
2774
|
+
setDismissedMenuValue(next)
|
|
2775
|
+
registerDraftImage(inspection, marker)
|
|
2776
|
+
notify(`${inspection.name} ready for the next message`)
|
|
2777
|
+
}, (reason: unknown) => {
|
|
2778
|
+
notify(`image attachment failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
|
|
2779
|
+
})
|
|
2780
|
+
setCompletionIndex(0)
|
|
2781
|
+
setDismissedMenuValue(undefined)
|
|
2782
|
+
return
|
|
2783
|
+
}
|
|
2784
|
+
// Session rows carry the canonical @[label](dsh-session:…) token;
|
|
2439
2785
|
// file rows insert `@path` (directories keep their trailing slash).
|
|
2440
2786
|
const insertion = row.label.startsWith('@')
|
|
2441
2787
|
? row.label
|
|
@@ -2443,15 +2789,6 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2443
2789
|
setValue(value.slice(0, mentionToken.start) + insertion + value.slice(cursor))
|
|
2444
2790
|
setCursor(mentionToken.start + insertion.length)
|
|
2445
2791
|
}
|
|
2446
|
-
} else if (pathActive) {
|
|
2447
|
-
const row = pathRows[completionIndex % Math.max(1, pathRows.length)]
|
|
2448
|
-
if (row !== undefined) {
|
|
2449
|
-
// Bare path completion replaces the typed token with the chosen
|
|
2450
|
-
// workspace path (directories keep their trailing slash).
|
|
2451
|
-
const insertion = row.kind === 'directory' ? `${row.label}/` : row.label
|
|
2452
|
-
setValue(value.slice(0, pathTokenStart) + insertion + value.slice(cursor))
|
|
2453
|
-
setCursor(pathTokenStart + insertion.length)
|
|
2454
|
-
}
|
|
2455
2792
|
} else {
|
|
2456
2793
|
const candidate = candidates[completionIndex % candidates.length]
|
|
2457
2794
|
if (candidate !== undefined) {
|
|
@@ -2463,9 +2800,27 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2463
2800
|
setDismissedMenuValue(undefined)
|
|
2464
2801
|
}
|
|
2465
2802
|
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
if (
|
|
2803
|
+
/** Apply one editor edit: draft, cursor, kill buffer, menu reset. */
|
|
2804
|
+
const applyEdit = (edit: EditResult): void => {
|
|
2805
|
+
if (edit.killed !== undefined && edit.killed !== '') killRef.current = edit.killed
|
|
2806
|
+
setValue(edit.value)
|
|
2807
|
+
setCursor(edit.cursor)
|
|
2808
|
+
preferredColumnRef.current = null
|
|
2809
|
+
setCompletionIndex(0)
|
|
2810
|
+
setDismissedMenuValue(undefined)
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
/** Move the cursor without editing; horizontal moves clear the column preference. */
|
|
2814
|
+
const moveCursorTo = (next: number): void => {
|
|
2815
|
+
if (next === cursor) return
|
|
2816
|
+
setCursor(next)
|
|
2817
|
+
preferredColumnRef.current = null
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
useInput((input, key) => {
|
|
2821
|
+
// Modal ownership: approval/question/model dialogs consume all keys.
|
|
2822
|
+
if (!active) return
|
|
2823
|
+
if (preparingImages) return
|
|
2469
2824
|
// Deletion confirm owns the box: y proceeds, anything else cancels.
|
|
2470
2825
|
// Typed in the INPUT BOX (codex delete-confirm): the keystroke is echoed
|
|
2471
2826
|
// as the box's own prompt, not an invisible panel keypress.
|
|
@@ -2505,9 +2860,11 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2505
2860
|
if (key.ctrl && input === 'c') {
|
|
2506
2861
|
if (busy) {
|
|
2507
2862
|
interrupt()
|
|
2508
|
-
} else if (value !== '') {
|
|
2509
|
-
setValue('')
|
|
2510
|
-
setCursor(0)
|
|
2863
|
+
} else if (value !== '') {
|
|
2864
|
+
setValue('')
|
|
2865
|
+
setCursor(0)
|
|
2866
|
+
draftImagesRef.current = []
|
|
2867
|
+
setDraftImages([])
|
|
2511
2868
|
setCompletionIndex(0)
|
|
2512
2869
|
setDismissedMenuValue(undefined)
|
|
2513
2870
|
} else {
|
|
@@ -2516,6 +2873,12 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2516
2873
|
return
|
|
2517
2874
|
}
|
|
2518
2875
|
if (key.ctrl && input === 'd') {
|
|
2876
|
+
// Codex: Ctrl+D deletes forward while a draft exists; the app-level
|
|
2877
|
+
// exit only fires from an empty composer.
|
|
2878
|
+
if (value !== '') {
|
|
2879
|
+
applyEdit(deleteForward(value, cursor))
|
|
2880
|
+
return
|
|
2881
|
+
}
|
|
2519
2882
|
if (busy) notify('cancel the running turn before exiting (Esc or Ctrl+C)', 'warning')
|
|
2520
2883
|
else quit()
|
|
2521
2884
|
return
|
|
@@ -2539,13 +2902,9 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2539
2902
|
return
|
|
2540
2903
|
}
|
|
2541
2904
|
if (key.return) {
|
|
2542
|
-
//
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
if (key.meta || (key.ctrl && input === 'j')) {
|
|
2546
|
-
setValue(value.slice(0, cursor) + '\n' + value.slice(cursor))
|
|
2547
|
-
setCursor(cursor + 1)
|
|
2548
|
-
setDismissedMenuValue(undefined)
|
|
2905
|
+
// A newline inside an open bracketed paste inserts; it never submits.
|
|
2906
|
+
if (pasteBracketRef.current) {
|
|
2907
|
+
applyEdit(insertText(value, cursor, '\n'))
|
|
2549
2908
|
return
|
|
2550
2909
|
}
|
|
2551
2910
|
// Enter on an open completion menu accepts the highlighted candidate
|
|
@@ -2554,14 +2913,42 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2554
2913
|
// exactly, in which case Enter submits it (typing a full "/effort" and
|
|
2555
2914
|
// pressing return must run the command, not re-accept its own text).
|
|
2556
2915
|
if (menuActive) {
|
|
2557
|
-
const exactSlash = !mentionActive &&
|
|
2916
|
+
const exactSlash = !mentionActive && candidates.some(candidate => candidate.label === value)
|
|
2558
2917
|
if (!exactSlash) {
|
|
2559
2918
|
acceptMenuCandidate()
|
|
2560
2919
|
return
|
|
2561
2920
|
}
|
|
2562
|
-
}
|
|
2563
|
-
const text = value.trim()
|
|
2564
|
-
|
|
2921
|
+
}
|
|
2922
|
+
const text = value.trim()
|
|
2923
|
+
if (draftImagesRef.current.length > 0) {
|
|
2924
|
+
setPreparingImages(true)
|
|
2925
|
+
notify(`processing ${draftImagesRef.current.length} image${draftImagesRef.current.length === 1 ? '' : 's'}…`)
|
|
2926
|
+
const snapshot = draftImagesRef.current
|
|
2927
|
+
void prepareImages(snapshot.map(image => image.path)).then((images) => {
|
|
2928
|
+
setPreparingImages(false)
|
|
2929
|
+
valueRef.current = ''
|
|
2930
|
+
cursorRef.current = 0
|
|
2931
|
+
setValue('')
|
|
2932
|
+
setCursor(0)
|
|
2933
|
+
draftImagesRef.current = []
|
|
2934
|
+
setDraftImages([])
|
|
2935
|
+
setCompletionIndex(0)
|
|
2936
|
+
setDismissedMenuValue(undefined)
|
|
2937
|
+
dismissNotice()
|
|
2938
|
+
if (text !== '') {
|
|
2939
|
+
recordLocal(text)
|
|
2940
|
+
recordHistory(text)
|
|
2941
|
+
}
|
|
2942
|
+
recall.current = beginRecall(recallSpace, '')
|
|
2943
|
+
if (busy) steer(text, images)
|
|
2944
|
+
else dispatch(text, images)
|
|
2945
|
+
}, (reason: unknown) => {
|
|
2946
|
+
setPreparingImages(false)
|
|
2947
|
+
notify(`image submission failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
|
|
2948
|
+
})
|
|
2949
|
+
return
|
|
2950
|
+
}
|
|
2951
|
+
setValue('')
|
|
2565
2952
|
setCursor(0)
|
|
2566
2953
|
setCompletionIndex(0)
|
|
2567
2954
|
setDismissedMenuValue(undefined)
|
|
@@ -2574,7 +2961,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2574
2961
|
recordLocal(text)
|
|
2575
2962
|
recordHistory(text)
|
|
2576
2963
|
}
|
|
2577
|
-
recall.current =
|
|
2964
|
+
recall.current = beginRecall(recallSpace, '')
|
|
2578
2965
|
if (text === '/quit') {
|
|
2579
2966
|
quit()
|
|
2580
2967
|
return
|
|
@@ -2606,6 +2993,21 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2606
2993
|
notify(outcome, tone)
|
|
2607
2994
|
return
|
|
2608
2995
|
}
|
|
2996
|
+
if (text === '/copy') {
|
|
2997
|
+
void copyLastResponse().then(
|
|
2998
|
+
outcome => notify(outcome),
|
|
2999
|
+
error => notify(`copy failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
|
|
3000
|
+
)
|
|
3001
|
+
return
|
|
3002
|
+
}
|
|
3003
|
+
if (text === '/diff' || text.startsWith('/diff ')) {
|
|
3004
|
+
openDiff(text.slice(5))
|
|
3005
|
+
return
|
|
3006
|
+
}
|
|
3007
|
+
if (text === '/review' || text.startsWith('/review ')) {
|
|
3008
|
+
reviewChanges(text.slice(7))
|
|
3009
|
+
return
|
|
3010
|
+
}
|
|
2609
3011
|
if (text === '/model' || text.startsWith('/model ')) {
|
|
2610
3012
|
openModel()
|
|
2611
3013
|
return
|
|
@@ -2642,10 +3044,18 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2642
3044
|
createSession(text.slice(4).trim() || undefined)
|
|
2643
3045
|
return
|
|
2644
3046
|
}
|
|
3047
|
+
if (text === '/fork' || text.startsWith('/fork ')) {
|
|
3048
|
+
forkSession(text.slice(5))
|
|
3049
|
+
return
|
|
3050
|
+
}
|
|
2645
3051
|
if (text === '/plugin' || text.startsWith('/plugin ')) {
|
|
2646
3052
|
openPlugin(text.slice(7).trim())
|
|
2647
3053
|
return
|
|
2648
3054
|
}
|
|
3055
|
+
if (text === '/jobs' || text.startsWith('/jobs ')) {
|
|
3056
|
+
openJobs()
|
|
3057
|
+
return
|
|
3058
|
+
}
|
|
2649
3059
|
if (text === '/statusline') {
|
|
2650
3060
|
openStatusline()
|
|
2651
3061
|
return
|
|
@@ -2662,6 +3072,10 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2662
3072
|
openAgents()
|
|
2663
3073
|
return
|
|
2664
3074
|
}
|
|
3075
|
+
if (text === '/todos') {
|
|
3076
|
+
openTodos()
|
|
3077
|
+
return
|
|
3078
|
+
}
|
|
2665
3079
|
if (text === '/subagent') {
|
|
2666
3080
|
openSubagent()
|
|
2667
3081
|
return
|
|
@@ -2680,6 +3094,9 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2680
3094
|
dispatch(text)
|
|
2681
3095
|
return
|
|
2682
3096
|
}
|
|
3097
|
+
// Ink exposes Ctrl+J as a bare LF and Alt+Enter as a bare CR after
|
|
3098
|
+
// stripping the leading escape. Neither is a multiline shortcut.
|
|
3099
|
+
if (input === '\n' || input === '\r') return
|
|
2683
3100
|
if (menuActive && key.upArrow) {
|
|
2684
3101
|
setCompletionIndex(index => (index + menuRows.length - 1) % menuRows.length)
|
|
2685
3102
|
return
|
|
@@ -2688,29 +3105,66 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2688
3105
|
setCompletionIndex(index => (index + 1) % menuRows.length)
|
|
2689
3106
|
return
|
|
2690
3107
|
}
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
if (
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
if (
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
3108
|
+
// Raw-annotated keys (Home/End, the delete family): Ink's own flags for
|
|
3109
|
+
// the same chunk are blank or blurred, so the read-patch annotation is
|
|
3110
|
+
// authoritative whenever it is set.
|
|
3111
|
+
const rawKey = rawAnnotation.current
|
|
3112
|
+
if (rawKey !== undefined) {
|
|
3113
|
+
if (rawKey === 'home') moveCursorTo(lineBounds(value, cursor).start)
|
|
3114
|
+
else if (rawKey === 'end') moveCursorTo(lineBounds(value, cursor).end)
|
|
3115
|
+
else if (rawKey === 'delete-backward') applyEdit(deleteBackward(value, cursor))
|
|
3116
|
+
else if (rawKey === 'delete-word-backward') applyEdit(deleteWordBackward(value, cursor))
|
|
3117
|
+
else if (rawKey === 'delete-forward') applyEdit(deleteForward(value, cursor))
|
|
3118
|
+
else applyEdit(deleteWordForward(value, cursor))
|
|
3119
|
+
return
|
|
3120
|
+
}
|
|
3121
|
+
if (key.upArrow || key.downArrow) {
|
|
3122
|
+
// Codex boundary gate: shell recall runs from an empty draft, or from
|
|
3123
|
+
// a boundary of a draft that still matches the last recalled entry;
|
|
3124
|
+
// every interior Up/Down moves the caret across the multiline draft.
|
|
3125
|
+
if (recall.current.entries.length > 0 && shouldRecallNavigate(value, cursor, recall.current.lastRecalled)) {
|
|
3126
|
+
const step = key.upArrow ? recallOlder(recall.current, value) : recallNewer(recall.current)
|
|
3127
|
+
recall.current = step.state
|
|
3128
|
+
if (step.entry !== undefined) {
|
|
3129
|
+
const safe = sanitizeDraftText(step.entry)
|
|
3130
|
+
setValue(safe)
|
|
3131
|
+
setCursor(safe.length)
|
|
3132
|
+
preferredColumnRef.current = null
|
|
3133
|
+
setDismissedMenuValue(undefined)
|
|
3134
|
+
}
|
|
3135
|
+
return
|
|
3136
|
+
}
|
|
3137
|
+
const model = editorModel(value, Math.max(1, columns - 6))
|
|
3138
|
+
const preferred = preferredColumnRef.current ?? caretSite(model, cursor).column
|
|
3139
|
+
const next = moveCursorVertically(model, cursor, preferred, key.upArrow ? -1 : 1)
|
|
3140
|
+
if (next !== cursor) {
|
|
3141
|
+
setCursor(next)
|
|
3142
|
+
preferredColumnRef.current = preferred
|
|
2703
3143
|
}
|
|
2704
3144
|
return
|
|
2705
3145
|
}
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
if (
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
3146
|
+
// Ctrl+P / Ctrl+N share the Up/Down contract (Codex binds them to
|
|
3147
|
+
// move_up/move_down, so the history gate applies first).
|
|
3148
|
+
if (key.ctrl && (input === 'p' || input === 'n')) {
|
|
3149
|
+
const up = input === 'p'
|
|
3150
|
+
if (recall.current.entries.length > 0 && shouldRecallNavigate(value, cursor, recall.current.lastRecalled)) {
|
|
3151
|
+
const step = up ? recallOlder(recall.current, value) : recallNewer(recall.current)
|
|
3152
|
+
recall.current = step.state
|
|
3153
|
+
if (step.entry !== undefined) {
|
|
3154
|
+
const safe = sanitizeDraftText(step.entry)
|
|
3155
|
+
setValue(safe)
|
|
3156
|
+
setCursor(safe.length)
|
|
3157
|
+
preferredColumnRef.current = null
|
|
3158
|
+
setDismissedMenuValue(undefined)
|
|
3159
|
+
}
|
|
3160
|
+
return
|
|
3161
|
+
}
|
|
3162
|
+
const model = editorModel(value, Math.max(1, columns - 6))
|
|
3163
|
+
const preferred = preferredColumnRef.current ?? caretSite(model, cursor).column
|
|
3164
|
+
const next = moveCursorVertically(model, cursor, preferred, up ? -1 : 1)
|
|
3165
|
+
if (next !== cursor) {
|
|
3166
|
+
setCursor(next)
|
|
3167
|
+
preferredColumnRef.current = preferred
|
|
2714
3168
|
}
|
|
2715
3169
|
return
|
|
2716
3170
|
}
|
|
@@ -2718,33 +3172,68 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2718
3172
|
acceptMenuCandidate()
|
|
2719
3173
|
return
|
|
2720
3174
|
}
|
|
3175
|
+
// Codex editor keymap: Alt/Ctrl+arrows and Alt+B/F move by word pieces;
|
|
3176
|
+
// plain arrows and Ctrl+B/F move by grapheme.
|
|
3177
|
+
if (key.leftArrow) {
|
|
3178
|
+
moveCursorTo(key.meta || key.ctrl ? moveWordLeft(value, cursor) : moveCursorBy(value, cursor, -1))
|
|
3179
|
+
return
|
|
3180
|
+
}
|
|
3181
|
+
if (key.rightArrow) {
|
|
3182
|
+
moveCursorTo(key.meta || key.ctrl ? moveWordRight(value, cursor) : moveCursorBy(value, cursor, 1))
|
|
3183
|
+
return
|
|
3184
|
+
}
|
|
3185
|
+
if (key.meta && input === 'b') {
|
|
3186
|
+
moveCursorTo(moveWordLeft(value, cursor))
|
|
3187
|
+
return
|
|
3188
|
+
}
|
|
3189
|
+
if (key.meta && input === 'f') {
|
|
3190
|
+
moveCursorTo(moveWordRight(value, cursor))
|
|
3191
|
+
return
|
|
3192
|
+
}
|
|
3193
|
+
if (key.ctrl && input === 'b') {
|
|
3194
|
+
moveCursorTo(moveCursorBy(value, cursor, -1))
|
|
3195
|
+
return
|
|
3196
|
+
}
|
|
3197
|
+
if (key.ctrl && input === 'f') {
|
|
3198
|
+
moveCursorTo(moveCursorBy(value, cursor, 1))
|
|
3199
|
+
return
|
|
3200
|
+
}
|
|
3201
|
+
// Ctrl+W and Alt+Backspace delete the previous word piece into the kill
|
|
3202
|
+
// buffer; Alt+D and the raw Ctrl/Alt+Delete variants kill forward.
|
|
3203
|
+
if (key.ctrl && input === 'w') {
|
|
3204
|
+
applyEdit(deleteWordBackward(value, cursor))
|
|
3205
|
+
return
|
|
3206
|
+
}
|
|
3207
|
+
if (key.meta && input === 'd') {
|
|
3208
|
+
applyEdit(deleteWordForward(value, cursor))
|
|
3209
|
+
return
|
|
3210
|
+
}
|
|
3211
|
+
// Un-annotated backspace/delete (Ink maps both and here):
|
|
3212
|
+
// delete the grapheme before the cursor.
|
|
2721
3213
|
if (key.backspace || key.delete) {
|
|
2722
|
-
|
|
2723
|
-
setValue(value.slice(0, cursor - 1) + value.slice(cursor))
|
|
2724
|
-
setCursor(cursor - 1)
|
|
2725
|
-
setCompletionIndex(0)
|
|
2726
|
-
setDismissedMenuValue(undefined)
|
|
2727
|
-
}
|
|
3214
|
+
applyEdit(deleteBackward(value, cursor))
|
|
2728
3215
|
return
|
|
2729
3216
|
}
|
|
2730
|
-
|
|
2731
|
-
|
|
3217
|
+
// Readline parity over the LOGICAL line: A/E to its ends, U/K kill to
|
|
3218
|
+
// them (filling the single kill buffer), Y yanks it back.
|
|
3219
|
+
if (key.ctrl && input === 'a') {
|
|
3220
|
+
moveCursorTo(lineBounds(value, cursor).start)
|
|
2732
3221
|
return
|
|
2733
3222
|
}
|
|
2734
|
-
if (key.
|
|
2735
|
-
|
|
3223
|
+
if (key.ctrl && input === 'e') {
|
|
3224
|
+
moveCursorTo(lineBounds(value, cursor).end)
|
|
2736
3225
|
return
|
|
2737
3226
|
}
|
|
2738
3227
|
if (key.ctrl && input === 'u') {
|
|
2739
|
-
|
|
2740
|
-
setCursor(0)
|
|
2741
|
-
setDismissedMenuValue(undefined)
|
|
3228
|
+
applyEdit(killToLineStart(value, cursor))
|
|
2742
3229
|
return
|
|
2743
3230
|
}
|
|
2744
|
-
// Readline parity: Ctrl+K cuts from the cursor to the end of the line.
|
|
2745
3231
|
if (key.ctrl && input === 'k') {
|
|
2746
|
-
|
|
2747
|
-
|
|
3232
|
+
applyEdit(killToLineEnd(value, cursor))
|
|
3233
|
+
return
|
|
3234
|
+
}
|
|
3235
|
+
if (key.ctrl && input === 'y') {
|
|
3236
|
+
if (killRef.current !== '') applyEdit(insertText(value, cursor, killRef.current))
|
|
2748
3237
|
return
|
|
2749
3238
|
}
|
|
2750
3239
|
// Ctrl+L refreshes the screen (readline convention): raw ANSI clear
|
|
@@ -2754,24 +3243,44 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2754
3243
|
refresh()
|
|
2755
3244
|
return
|
|
2756
3245
|
}
|
|
2757
|
-
if (key.ctrl && input === 'a') {
|
|
2758
|
-
setCursor(0)
|
|
2759
|
-
return
|
|
2760
|
-
}
|
|
2761
|
-
if (key.ctrl && input === 'e') {
|
|
2762
|
-
setCursor(value.length)
|
|
2763
|
-
return
|
|
2764
|
-
}
|
|
2765
3246
|
if (input !== '' && !key.ctrl && !key.meta) {
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
3247
|
+
// Bracketed-paste wrappers arrive as unknown escape sequences stripped
|
|
3248
|
+
// of their ESC. Markers may ride their own chunk or the edges of a
|
|
3249
|
+
// content chunk; strip every occurrence and track the open-paste flag
|
|
3250
|
+
// so a chunk that is exactly LF inserts instead of submitting.
|
|
3251
|
+
let text = input
|
|
3252
|
+
if (text.includes(PASTE_START_MARKER)) {
|
|
3253
|
+
pasteBracketRef.current = true
|
|
3254
|
+
// Arm the lost-marker safety net: one timer per open paste, re-armed
|
|
3255
|
+
// if a second start marker rides the same burst.
|
|
3256
|
+
pasteBracketCancelRef.current?.()
|
|
3257
|
+
const timer = setTimeout(() => {
|
|
3258
|
+
pasteBracketRef.current = false
|
|
3259
|
+
pasteBracketCancelRef.current = undefined
|
|
3260
|
+
}, PASTE_BRACKET_TIMEOUT_MS)
|
|
3261
|
+
pasteBracketCancelRef.current = () => {
|
|
3262
|
+
clearTimeout(timer)
|
|
3263
|
+
pasteBracketCancelRef.current = undefined
|
|
3264
|
+
}
|
|
3265
|
+
text = text.replaceAll(PASTE_START_MARKER, '')
|
|
3266
|
+
}
|
|
3267
|
+
if (text.includes(PASTE_END_MARKER)) {
|
|
3268
|
+
pasteBracketRef.current = false
|
|
3269
|
+
pasteBracketCancelRef.current?.()
|
|
3270
|
+
text = text.replaceAll(PASTE_END_MARKER, '')
|
|
3271
|
+
}
|
|
3272
|
+
if (text === '') return
|
|
3273
|
+
const droppedPaths = text.length > 1 ? parsePastedImagePaths(text) : []
|
|
3274
|
+
if (droppedPaths.length > 0) {
|
|
3275
|
+
insertDroppedImages(droppedPaths)
|
|
3276
|
+
return
|
|
3277
|
+
}
|
|
3278
|
+
applyEdit(insertText(value, cursor, text))
|
|
2770
3279
|
}
|
|
2771
3280
|
})
|
|
2772
3281
|
|
|
2773
3282
|
// The DeepSeek easter-egg wave owns its 33ms tick HERE instead of in App:
|
|
2774
|
-
// the interval re-renders only the composer
|
|
3283
|
+
// the interval re-renders only the composer band at 30fps, never the whole
|
|
2775
3284
|
// tree. App drives the tier/style pair on a model switch; this local effect
|
|
2776
3285
|
// starts the sweep whenever that pair changes (App picks a NEW random style
|
|
2777
3286
|
// for every replay — including effort changes on the same route — so the
|
|
@@ -2812,44 +3321,68 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2812
3321
|
const tierHues = waveTier === null ? null : deepseekWaveHues(waveTier)
|
|
2813
3322
|
const promptColor = tierHues === null ? inkColor(getPalette().brand) : inkColor(tierHues[0])
|
|
2814
3323
|
const promptGlyph = waveTier === 'flash' ? '›' : waveTier === 'deepseek' ? '»' : '❯'
|
|
3324
|
+
// The multiline editor model: the sanitized draft hard-wrapped into
|
|
3325
|
+
// column-safe physical rows, with the caret mapped to its exact row and
|
|
3326
|
+
// column. Computed before the frozen path so the row report below runs
|
|
3327
|
+
// unconditionally.
|
|
3328
|
+
const editorColumns = Math.max(1, columns - 6)
|
|
3329
|
+
const editorViewModel = editorModel(value, editorColumns)
|
|
3330
|
+
const clampedCursor = clampCursor(value, cursor)
|
|
3331
|
+
const caret = caretSite(editorViewModel, clampedCursor)
|
|
3332
|
+
const editorWindowRows = Math.min(editorViewModel.rows.length, Math.max(1, maxRows))
|
|
3333
|
+
const maxEditorScroll = Math.max(0, editorViewModel.rows.length - editorWindowRows)
|
|
3334
|
+
const currentEditorScroll = Math.min(Math.max(0, editorScrollRef.current), maxEditorScroll)
|
|
3335
|
+
// Codex effective_scroll: no scrolling while the rows fit; otherwise the
|
|
3336
|
+
// window follows the caret row with as little movement as possible.
|
|
3337
|
+
const editorWindowStart = caret.row < currentEditorScroll
|
|
3338
|
+
? caret.row
|
|
3339
|
+
: caret.row >= currentEditorScroll + editorWindowRows
|
|
3340
|
+
? caret.row - editorWindowRows + 1
|
|
3341
|
+
: currentEditorScroll
|
|
3342
|
+
editorScrollRef.current = editorWindowStart
|
|
3343
|
+
const editorRowCount = frozen ? 1 : editorWindowRows
|
|
3344
|
+
useEffect(() => {
|
|
3345
|
+
onEditorRows(editorRowCount)
|
|
3346
|
+
}, [editorRowCount, onEditorRows])
|
|
3347
|
+
// The composer band: the old border's three-row footprint repainted as a
|
|
3348
|
+
// background-color band (the Codex-style shaded composer strip) — one
|
|
3349
|
+
// blank band row above and below the content rows, full width minus the
|
|
3350
|
+
// final column. Row counts are untouched, so every height budget stays
|
|
3351
|
+
// exact.
|
|
3352
|
+
const bandWidth = Math.max(1, columns - 1)
|
|
3353
|
+
const bandBg = inkColor(getPalette().composerBand)
|
|
3354
|
+
const bandFill = (consumed: number): string => ' '.repeat(Math.max(0, bandWidth - consumed))
|
|
3355
|
+
const band = (content: ReactElement): ReactElement => createElement(
|
|
3356
|
+
Box,
|
|
3357
|
+
{ flexDirection: 'column', width: bandWidth },
|
|
3358
|
+
createElement(Text, { backgroundColor: bandBg }, ' '.repeat(bandWidth)),
|
|
3359
|
+
content,
|
|
3360
|
+
createElement(Text, { backgroundColor: bandBg }, ' '.repeat(bandWidth)),
|
|
3361
|
+
)
|
|
2815
3362
|
if (frozen) {
|
|
2816
|
-
// A pending deletion turns the
|
|
2817
|
-
// typed HERE
|
|
3363
|
+
// A pending deletion turns the band into the confirm prompt: the y/n is
|
|
3364
|
+
// typed HERE; without a border the warn color carries the warning.
|
|
2818
3365
|
if (deleteConfirm !== undefined) {
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
),
|
|
2828
|
-
)
|
|
3366
|
+
const warning = 'y delete · any other key cancels'
|
|
3367
|
+
return band(createElement(
|
|
3368
|
+
Text,
|
|
3369
|
+
{ backgroundColor: bandBg, wrap: 'truncate-end' },
|
|
3370
|
+
createElement(Text, { color: inkColor(getPalette().warn), bold: true }, '❯ '),
|
|
3371
|
+
createElement(Text, { color: inkColor(getPalette().warn), bold: true }, warning),
|
|
3372
|
+
bandFill(2 + visibleColumns(warning)),
|
|
3373
|
+
))
|
|
2829
3374
|
}
|
|
2830
|
-
const
|
|
3375
|
+
const frozenLine = value === ''
|
|
2831
3376
|
? 'type a message'
|
|
2832
3377
|
: verboseLine(value, Math.max(1, columns - 6))
|
|
2833
|
-
return createElement(
|
|
2834
|
-
|
|
2835
|
-
{
|
|
2836
|
-
createElement(
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
frozen,
|
|
2841
|
-
),
|
|
2842
|
-
)
|
|
3378
|
+
return band(createElement(
|
|
3379
|
+
Text,
|
|
3380
|
+
{ backgroundColor: bandBg, wrap: 'truncate-end' },
|
|
3381
|
+
createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, busy ? '… ' : `${promptGlyph} `),
|
|
3382
|
+
frozenLine,
|
|
3383
|
+
bandFill(2 + visibleColumns(frozenLine)),
|
|
3384
|
+
))
|
|
2843
3385
|
}
|
|
2844
|
-
|
|
2845
|
-
// The bordered frame: static dim at rest; while the wave runs the border
|
|
2846
|
-
// breathes with the sweep (dim blends toward the tier accent and back), so
|
|
2847
|
-
// the frame glows up while the crest crosses the row.
|
|
2848
|
-
const frame = (row: ReactElement, borderRgb?: RgbTriple): ReactElement => createElement(
|
|
2849
|
-
Box,
|
|
2850
|
-
{ width: Math.max(1, columns - 1), borderStyle: 'round', borderColor: inkColor(borderRgb ?? getPalette().dim), paddingX: 1 },
|
|
2851
|
-
row,
|
|
2852
|
-
)
|
|
2853
3386
|
const menu = createElement(CompletionMenu, {
|
|
2854
3387
|
active: menuActive,
|
|
2855
3388
|
mention: mentionActive,
|
|
@@ -2857,56 +3390,87 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2857
3390
|
rows: menuRows,
|
|
2858
3391
|
})
|
|
2859
3392
|
|
|
2860
|
-
//
|
|
2861
|
-
//
|
|
2862
|
-
//
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
3393
|
+
// The multiline editor (idle, busy, or after the wave): every visible
|
|
3394
|
+
// physical row renders inside the band, the prompt marker leading the
|
|
3395
|
+
// first and a two-space indent aligning continuations under the text
|
|
3396
|
+
// column — the same gutter reply rows use. The caret is the inverse block
|
|
3397
|
+
// on its exact grapheme, so wide CJK cells and emoji clusters position the
|
|
3398
|
+
// block precisely. The prompt marker keeps the tier accent while an
|
|
3399
|
+
// official DeepSeek model is applied, restoring the static brand ❯ on any
|
|
3400
|
+
// other route.
|
|
3401
|
+
const editorRows: ReactElement[] = []
|
|
3402
|
+
for (let index = editorWindowStart; index < Math.min(editorViewModel.rows.length, editorWindowStart + editorWindowRows); index += 1) {
|
|
3403
|
+
const row = editorViewModel.rows[index]!
|
|
3404
|
+
const caretAt = index === caret.row ? row.offsets.indexOf(clampedCursor) : -1
|
|
3405
|
+
const before = caretAt > 0 ? row.text.slice(0, row.cuts[caretAt]!) : ''
|
|
3406
|
+
const caretChar = caretAt >= 0 && caretAt < row.cuts.length - 1 ? row.text.slice(row.cuts[caretAt]!, row.cuts[caretAt + 1]!) : ' '
|
|
3407
|
+
const after = caretAt < 0
|
|
3408
|
+
? row.text
|
|
3409
|
+
: caretAt < row.cuts.length - 1
|
|
3410
|
+
? row.text.slice(row.cuts[caretAt + 1]!)
|
|
3411
|
+
: ''
|
|
3412
|
+
const placeholder = index === 0 && value === '' && !busy
|
|
3413
|
+
const tail = placeholder ? COMPOSER_PLACEHOLDER : after
|
|
3414
|
+
const consumed = 2 + visibleColumns(before) + visibleColumns(caretChar) + visibleColumns(tail)
|
|
3415
|
+
editorRows.push(createElement(
|
|
3416
|
+
Text,
|
|
3417
|
+
{ key: index, backgroundColor: bandBg, wrap: 'truncate-end' },
|
|
3418
|
+
index === 0
|
|
3419
|
+
? busy
|
|
3420
|
+
? createElement(BusyChase)
|
|
3421
|
+
: createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, `${promptGlyph} `)
|
|
3422
|
+
: ' ',
|
|
3423
|
+
before,
|
|
3424
|
+
createElement(CursorBlock, { key: 'caret', char: caretChar }),
|
|
3425
|
+
placeholder
|
|
3426
|
+
? createElement(Text, { dimColor: true }, COMPOSER_PLACEHOLDER)
|
|
3427
|
+
: after,
|
|
3428
|
+
bandFill(consumed),
|
|
3429
|
+
))
|
|
3430
|
+
}
|
|
3431
|
+
const staticEditor = createElement(Box, { flexDirection: 'column' }, ...editorRows)
|
|
2876
3432
|
|
|
2877
|
-
// Wave
|
|
2878
|
-
// the sampled wave `backgroundColor` (null outside the crest →
|
|
2879
|
-
// so the crest sweeps the FULL
|
|
2880
|
-
// placeholder, and the trailing
|
|
3433
|
+
// Wave band: all three rows assembled column by column, each cell carrying
|
|
3434
|
+
// the sampled wave `backgroundColor` (null outside the crest → the band
|
|
3435
|
+
// background), so the crest sweeps the FULL band — blank rows, prompt,
|
|
3436
|
+
// draft, cursor, placeholder, and the trailing fill — with a per-row phase
|
|
3437
|
+
// offset that flows the wave down the band. The deepseek tier drops the
|
|
2881
3438
|
// `· ✦ ✧` sparkles into the rightmost blank cell from 900ms on.
|
|
2882
3439
|
const waveRow = (): ReactElement => {
|
|
2883
|
-
const contentWidth = Math.max(1, columns - 5)
|
|
2884
|
-
const waveEditor = editorWindow(value, cursor, Math.max(1, contentWidth - 2))
|
|
2885
3440
|
const hues = deepseekWaveHues(waveTier!)
|
|
2886
3441
|
const style = waveStyle!
|
|
2887
|
-
const
|
|
2888
|
-
const waveBg = (column: number): string
|
|
2889
|
-
const rgb = deepseekWaveColumnBg(waveTick!, column,
|
|
2890
|
-
return rgb === null ?
|
|
2891
|
-
}
|
|
2892
|
-
const
|
|
2893
|
-
|
|
2894
|
-
|
|
3442
|
+
const bandRgb = getPalette().composerBand
|
|
3443
|
+
const waveBg = (row: number, column: number): string => {
|
|
3444
|
+
const rgb = deepseekWaveColumnBg(waveTick!, column, bandWidth, waveTier!, style, hues, bandRgb, row, 3)
|
|
3445
|
+
return rgb === null ? bandBg : inkColor(rgb)
|
|
3446
|
+
}
|
|
3447
|
+
const blankBandRow = (row: number): ReactElement => {
|
|
3448
|
+
const blanks: ComposerCell[] = []
|
|
3449
|
+
while (blanks.length < bandWidth) blanks.push({ char: ' ', backgroundColor: waveBg(row, blanks.length) })
|
|
3450
|
+
return createElement(Text, { key: row }, ...waveRowSpans(blanks))
|
|
3451
|
+
}
|
|
3452
|
+
const waveEditor = editorWindow(value, cursor, Math.max(1, columns - 7))
|
|
3453
|
+
const cells: ComposerCell[] = [
|
|
3454
|
+
{ char: ' ', backgroundColor: waveBg(1, 0) },
|
|
3455
|
+
{ char: ' ', backgroundColor: waveBg(1, 1) },
|
|
3456
|
+
{ char: promptGlyph, color: promptColor, bold: true, backgroundColor: waveBg(1, 2) },
|
|
3457
|
+
{ char: ' ', color: promptColor, backgroundColor: waveBg(1, 3) },
|
|
3458
|
+
]
|
|
2895
3459
|
for (const char of waveEditor.before) {
|
|
2896
|
-
cells.push({ char, backgroundColor: waveBg(cells.length) })
|
|
3460
|
+
cells.push({ char, backgroundColor: waveBg(1, cells.length) })
|
|
2897
3461
|
}
|
|
2898
|
-
cells.push({ char: waveEditor.caret, inverse: true, backgroundColor: waveBg(cells.length) })
|
|
3462
|
+
cells.push({ char: waveEditor.caret, inverse: true, backgroundColor: waveBg(1, cells.length) })
|
|
2899
3463
|
if (value === '' && !busy) {
|
|
2900
3464
|
for (let at = 0; at < COMPOSER_PLACEHOLDER.length; at += 1) {
|
|
2901
|
-
cells.push({ char: COMPOSER_PLACEHOLDER[at]!, dim: true, backgroundColor: waveBg(cells.length) })
|
|
3465
|
+
cells.push({ char: COMPOSER_PLACEHOLDER[at]!, dim: true, backgroundColor: waveBg(1, cells.length) })
|
|
2902
3466
|
}
|
|
2903
3467
|
} else {
|
|
2904
3468
|
for (const char of waveEditor.after) {
|
|
2905
|
-
cells.push({ char, backgroundColor: waveBg(cells.length) })
|
|
3469
|
+
cells.push({ char, backgroundColor: waveBg(1, cells.length) })
|
|
2906
3470
|
}
|
|
2907
3471
|
}
|
|
2908
|
-
while (cells.length <
|
|
2909
|
-
cells.push({ char: ' ', backgroundColor: waveBg(cells.length) })
|
|
3472
|
+
while (cells.length < bandWidth) {
|
|
3473
|
+
cells.push({ char: ' ', backgroundColor: waveBg(1, cells.length) })
|
|
2910
3474
|
}
|
|
2911
3475
|
// The wordmark rides the wave's middle: `deepseek` on the official
|
|
2912
3476
|
// tiers, `Into the Unknown` on the non-DeepSeek high-effort variant —
|
|
@@ -2914,7 +3478,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2914
3478
|
// over blank or placeholder cells — real draft text is never covered.
|
|
2915
3479
|
if (deepseekWaveWordVisible(waveTick!, waveTier!, style)) {
|
|
2916
3480
|
const word = waveTier === 'unknown' ? 'Into the Unknown' : 'deepseek'
|
|
2917
|
-
const start = Math.max(2, Math.floor((
|
|
3481
|
+
const start = Math.max(2, Math.floor((bandWidth - word.length) / 2))
|
|
2918
3482
|
let clear = true
|
|
2919
3483
|
for (let at = 0; at < word.length; at += 1) {
|
|
2920
3484
|
const cell = cells[start + at]
|
|
@@ -2945,15 +3509,20 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2945
3509
|
}
|
|
2946
3510
|
}
|
|
2947
3511
|
}
|
|
2948
|
-
|
|
2949
|
-
|
|
3512
|
+
return createElement(
|
|
3513
|
+
Box,
|
|
3514
|
+
{ flexDirection: 'column', width: bandWidth },
|
|
3515
|
+
blankBandRow(0),
|
|
3516
|
+
createElement(Text, { wrap: 'truncate-end' }, ...waveRowSpans(cells)),
|
|
3517
|
+
blankBandRow(2),
|
|
3518
|
+
)
|
|
2950
3519
|
}
|
|
2951
3520
|
|
|
2952
3521
|
return createElement(
|
|
2953
3522
|
Box,
|
|
2954
3523
|
{ flexDirection: 'column' },
|
|
2955
3524
|
menu,
|
|
2956
|
-
waveTick !== null && waveTier !== null && !busy ? waveRow() :
|
|
3525
|
+
waveTick !== null && waveTier !== null && !busy ? waveRow() : band(staticEditor),
|
|
2957
3526
|
)
|
|
2958
3527
|
}
|
|
2959
3528
|
|
|
@@ -2965,15 +3534,15 @@ interface SettledRowRecord {
|
|
|
2965
3534
|
before: ReactElement | undefined
|
|
2966
3535
|
/** The roomy-prompt spacer AFTER the row, or undefined. */
|
|
2967
3536
|
after: ReactElement | undefined
|
|
2968
|
-
/**
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
showReasoning: boolean
|
|
3537
|
+
/** Physical rows this record contributes (row body plus spacers) — the
|
|
3538
|
+
* unit of the rendered-history cap. */
|
|
3539
|
+
rows: number
|
|
2972
3540
|
}
|
|
2973
3541
|
|
|
2974
3542
|
/** The incremental settled-history cache (see `computeSettledRows`). */
|
|
2975
3543
|
interface SettledRowsCache {
|
|
2976
|
-
/** The exact settled entries the cache covers (
|
|
3544
|
+
/** The exact settled entries the cache covers (the WINDOW: the newest
|
|
3545
|
+
* `entries.length` settled entries, oldest dropped entries excluded). */
|
|
2977
3546
|
entries: TranscriptEntry[]
|
|
2978
3547
|
/** Records keyed by entry identity; mutated in place so the append path
|
|
2979
3548
|
* never copies the whole map. */
|
|
@@ -2984,10 +3553,21 @@ interface SettledRowsCache {
|
|
|
2984
3553
|
resumed: boolean
|
|
2985
3554
|
/** The toggle state the rows were built with. */
|
|
2986
3555
|
showReasoning: boolean
|
|
2987
|
-
/** The refreshEpoch the rows
|
|
3556
|
+
/** The refreshEpoch the rows was built for; a bump forces a full rebuild. */
|
|
2988
3557
|
epoch: number
|
|
2989
|
-
/** The
|
|
3558
|
+
/** The terminal width the rows were wrapped for; a change forces a rebuild. */
|
|
3559
|
+
columns: number
|
|
3560
|
+
/** The flat row list (header + optional hint + per-entry before/box/after). */
|
|
2990
3561
|
flat: ReactElement[]
|
|
3562
|
+
/** Settled entries dropped from the window's head (rendering only — the
|
|
3563
|
+
* event log keeps everything; Ctrl+O and /export read it directly). */
|
|
3564
|
+
droppedEntries: number
|
|
3565
|
+
/** Physical rows the window's entries contribute (excludes header/hint). */
|
|
3566
|
+
totalRows: number
|
|
3567
|
+
/** The window overflowed the trim hysteresis; one source-backed replay
|
|
3568
|
+
* (epoch bump) will re-window the cache. The append path never mutates
|
|
3569
|
+
* flat's head, so <Static> only ever sees tail appends between remounts. */
|
|
3570
|
+
needsTrim: boolean
|
|
2991
3571
|
}
|
|
2992
3572
|
|
|
2993
3573
|
/** One step of `computeSettledRows`. */
|
|
@@ -2997,23 +3577,36 @@ interface SettledRowsResult {
|
|
|
2997
3577
|
built: number
|
|
2998
3578
|
}
|
|
2999
3579
|
|
|
3000
|
-
/** Build one settled row (row Box plus its roomy-prompt spacers). */
|
|
3001
|
-
function buildSettledRow(entry: TranscriptEntry, index: number, showReasoning: boolean): SettledRowRecord {
|
|
3002
|
-
|
|
3580
|
+
/** Build one settled row (row Box plus its roomy-prompt spacers and row count). */
|
|
3581
|
+
function buildSettledRow(entry: TranscriptEntry, index: number, showReasoning: boolean, columns: number): SettledRowRecord {
|
|
3582
|
+
// The SAME physical-row pipeline as the live tail (settledEntryLines).
|
|
3583
|
+
// Every row carries its own two-column prefix (user ❯, reply body, tool
|
|
3584
|
+
// cards), which is the whole gutter: no extra container padding, so reply
|
|
3585
|
+
// text starts at the same column as the composer's input text and wrapped
|
|
3586
|
+
// continuations keep their hanging indent instead of resetting to column 0.
|
|
3003
3587
|
const roomyPrompt = entry.kind === 'user' && !entry.notice
|
|
3588
|
+
const lines = settledEntryLines(entry, Math.max(10, columns - 2), showReasoning)
|
|
3004
3589
|
return {
|
|
3005
|
-
box: createElement(Box, { key: index,
|
|
3590
|
+
box: createElement(Box, { key: index }, createElement(StyledRows, { lines })),
|
|
3006
3591
|
before: roomyPrompt
|
|
3007
3592
|
? createElement(Box, { key: `prompt-before-${index}`, paddingX: 1 }, createElement(Text, null, ' '))
|
|
3008
3593
|
: undefined,
|
|
3009
3594
|
after: roomyPrompt
|
|
3010
3595
|
? createElement(Box, { key: `prompt-after-${index}`, paddingX: 1 }, createElement(Text, null, ' '))
|
|
3011
3596
|
: undefined,
|
|
3012
|
-
|
|
3013
|
-
showReasoning,
|
|
3597
|
+
rows: lines.length + (roomyPrompt ? 2 : 0),
|
|
3014
3598
|
}
|
|
3015
3599
|
}
|
|
3016
3600
|
|
|
3601
|
+
/** The dim hint row placed under the header once the window has dropped entries. */
|
|
3602
|
+
function settledTrimHint(droppedEntries: number, columns: number): ReactElement {
|
|
3603
|
+
return createElement(
|
|
3604
|
+
Text,
|
|
3605
|
+
{ key: 'history-cap-hint', color: inkColor(getPalette().dim), wrap: 'truncate-end' },
|
|
3606
|
+
truncateColumns(`… +${droppedEntries} earlier messages hidden · ctrl+o browse · /export full transcript`, Math.max(10, columns - 2)),
|
|
3607
|
+
)
|
|
3608
|
+
}
|
|
3609
|
+
|
|
3017
3610
|
/**
|
|
3018
3611
|
* The settled `<Static>` row set as a PURE incremental state machine (App
|
|
3019
3612
|
* drives it from the memo; tests drive it directly and read `built`).
|
|
@@ -3027,11 +3620,27 @@ function buildSettledRow(entry: TranscriptEntry, index: number, showReasoning: b
|
|
|
3027
3620
|
* rebuild of rows, Map, or MarkdownBody parses). `records` is mutated in place
|
|
3028
3621
|
* on the append/toggle paths to stay O(delta).
|
|
3029
3622
|
*
|
|
3623
|
+
* RENDERED-HISTORY CAP: the window holds at most `rowCap` physical rows of
|
|
3624
|
+
* settled transcript (header and hint reserved on top). The cap exists only
|
|
3625
|
+
* here — the event log, the store projection, /export, Ctrl+O, and /resume
|
|
3626
|
+
* keep the full history. Ink 5's <Static> is a consumption counter
|
|
3627
|
+
* (items.slice(index) keyed on length): deleting head items mid-stream while
|
|
3628
|
+
* appending tail items can permanently swallow new rows, so the append branch
|
|
3629
|
+
* NEVER drops the head — it only accounts rows and flags `needsTrim` once the
|
|
3630
|
+
* window overflows cap + margin. The flag fires one source-backed replay
|
|
3631
|
+
* (epoch bump = the existing clear + <Static> remount), whose rebuild branch
|
|
3632
|
+
* walks the settled entries BACKWARD from the newest, keeps whole entries
|
|
3633
|
+
* until the cap, and counts everything older as `droppedEntries` (those
|
|
3634
|
+
* entries never even reach settledEntryLines). Hysteresis bounds replays to
|
|
3635
|
+
* at most one per 25% growth; resize / Ctrl+L / idle Ctrl+R replays re-window
|
|
3636
|
+
* for free on the same path.
|
|
3637
|
+
*
|
|
3030
3638
|
* Full rebuilds run only on the rare, deliberate paths: no cache yet, a
|
|
3031
|
-
* source-backed replay (`epoch` bump: resize / Ctrl+L / Ctrl+R
|
|
3032
|
-
* `<Static>` and must re-flush the CURRENT rows
|
|
3033
|
-
*
|
|
3034
|
-
*
|
|
3639
|
+
* source-backed replay (`epoch` bump: resize / Ctrl+L / an idle Ctrl+R fold
|
|
3640
|
+
* toggle / a cap trim remounts `<Static>` and must re-flush the CURRENT rows
|
|
3641
|
+
* at the CURRENT fold state), a `resumed` change, or a shrink (`store.reset`).
|
|
3642
|
+
* While a turn is busy or streaming, Ctrl+R only flips the live region; rows
|
|
3643
|
+
* already emitted to native scrollback change exclusively through rebuilds.
|
|
3035
3644
|
*/
|
|
3036
3645
|
export function computeSettledRows(
|
|
3037
3646
|
previous: SettledRowsCache | undefined,
|
|
@@ -3040,50 +3649,58 @@ export function computeSettledRows(
|
|
|
3040
3649
|
showReasoning: boolean,
|
|
3041
3650
|
resumed: boolean,
|
|
3042
3651
|
epoch: number,
|
|
3652
|
+
columns = 80,
|
|
3653
|
+
rowCap = SETTLED_ROW_CAP,
|
|
3043
3654
|
): SettledRowsResult {
|
|
3044
3655
|
if (previous === undefined || previous.epoch !== epoch || previous.resumed !== resumed
|
|
3045
3656
|
|| settled < previous.entries.length) {
|
|
3046
|
-
// Full rebuild
|
|
3657
|
+
// Full rebuild at the CURRENT fold state, newest-first so the cap keeps
|
|
3658
|
+
// whole entries and never even parses dropped ones.
|
|
3047
3659
|
const records = new Map<TranscriptEntry, SettledRowRecord>()
|
|
3048
|
-
const
|
|
3049
|
-
|
|
3660
|
+
const window: ReactElement[] = []
|
|
3661
|
+
let windowRows = 0
|
|
3662
|
+
let droppedEntries = 0
|
|
3663
|
+
let index = settled - 1
|
|
3664
|
+
for (; index >= 0; index--) {
|
|
3050
3665
|
const entry = entries[index]
|
|
3051
|
-
|
|
3666
|
+
if (entry === undefined) break
|
|
3667
|
+
const record = buildSettledRow(entry, index, showReasoning, columns)
|
|
3668
|
+
if (rowCap > 0 && windowRows + record.rows > rowCap - SETTLED_ROW_RESERVE) {
|
|
3669
|
+
// This whole entry (and everything older) falls out of the window.
|
|
3670
|
+
droppedEntries = index + 1
|
|
3671
|
+
break
|
|
3672
|
+
}
|
|
3052
3673
|
records.set(entry, record)
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3674
|
+
windowRows += record.rows
|
|
3675
|
+
if (record.after !== undefined) window.unshift(record.after)
|
|
3676
|
+
window.unshift(record.box)
|
|
3677
|
+
if (record.before !== undefined) window.unshift(record.before)
|
|
3056
3678
|
}
|
|
3679
|
+
const header = createElement(Header, { key: 'header', resumed })
|
|
3680
|
+
const flat = droppedEntries > 0
|
|
3681
|
+
? [header, settledTrimHint(droppedEntries, columns), ...window]
|
|
3682
|
+
: [header, ...window]
|
|
3057
3683
|
return {
|
|
3058
|
-
cache: {
|
|
3059
|
-
|
|
3684
|
+
cache: {
|
|
3685
|
+
entries: entries.slice(droppedEntries, settled),
|
|
3686
|
+
records,
|
|
3687
|
+
header,
|
|
3688
|
+
resumed,
|
|
3689
|
+
showReasoning,
|
|
3690
|
+
epoch,
|
|
3691
|
+
columns,
|
|
3692
|
+
flat,
|
|
3693
|
+
droppedEntries,
|
|
3694
|
+
totalRows: windowRows,
|
|
3695
|
+
needsTrim: false,
|
|
3696
|
+
},
|
|
3697
|
+
built: records.size,
|
|
3060
3698
|
}
|
|
3061
3699
|
}
|
|
3062
3700
|
if (previous.showReasoning !== showReasoning) {
|
|
3063
|
-
//
|
|
3064
|
-
//
|
|
3065
|
-
|
|
3066
|
-
const flat: ReactElement[] = [previous.header]
|
|
3067
|
-
let built = 0
|
|
3068
|
-
for (let index = 0; index < previous.entries.length; index++) {
|
|
3069
|
-
const entry = previous.entries[index]
|
|
3070
|
-
const record = records.get(entry)!
|
|
3071
|
-
const current = record.reasonSensitive
|
|
3072
|
-
? {
|
|
3073
|
-
...record,
|
|
3074
|
-
box: createElement(Box, { key: index, paddingX: 1 }, createElement(EntryLine, { entry, showReasoning, verbose: false })),
|
|
3075
|
-
showReasoning,
|
|
3076
|
-
}
|
|
3077
|
-
: record
|
|
3078
|
-
if (current !== record) {
|
|
3079
|
-
records.set(entry, current)
|
|
3080
|
-
built += 1
|
|
3081
|
-
}
|
|
3082
|
-
if (current.before !== undefined) flat.push(current.before)
|
|
3083
|
-
flat.push(current.box)
|
|
3084
|
-
if (current.after !== undefined) flat.push(current.after)
|
|
3085
|
-
}
|
|
3086
|
-
return { cache: { ...previous, records, showReasoning, flat }, built }
|
|
3701
|
+
// Native scrollback is immutable. Record only the mode future settled
|
|
3702
|
+
// entries will capture; the existing flat row identity stays untouched.
|
|
3703
|
+
return { cache: { ...previous, showReasoning }, built: 0 }
|
|
3087
3704
|
}
|
|
3088
3705
|
if (settled === previous.entries.length) {
|
|
3089
3706
|
// Nothing below the boundary changed (a pending retirement above it, a
|
|
@@ -3091,19 +3708,25 @@ export function computeSettledRows(
|
|
|
3091
3708
|
// memoized <Static> subtree does not re-render at all.
|
|
3092
3709
|
return { cache: previous, built: 0 }
|
|
3093
3710
|
}
|
|
3094
|
-
// The boundary grew: build ONLY the newly settled suffix.
|
|
3711
|
+
// The boundary grew: build ONLY the newly settled suffix. The head is never
|
|
3712
|
+
// dropped here (Ink's Static counter would swallow rows on a mixed frame);
|
|
3713
|
+
// overflow only flags the cache for one trimming replay.
|
|
3095
3714
|
const records = previous.records
|
|
3096
3715
|
const suffix: TranscriptEntry[] = []
|
|
3097
3716
|
const added: ReactElement[] = []
|
|
3098
|
-
|
|
3717
|
+
let deltaRows = 0
|
|
3718
|
+
for (let index = previous.entries.length + previous.droppedEntries; index < settled; index++) {
|
|
3099
3719
|
const entry = entries[index]
|
|
3100
|
-
const record = buildSettledRow(entry, index, showReasoning)
|
|
3720
|
+
const record = buildSettledRow(entry, index, showReasoning, previous.columns)
|
|
3101
3721
|
records.set(entry, record)
|
|
3102
3722
|
suffix.push(entry)
|
|
3723
|
+
deltaRows += record.rows
|
|
3103
3724
|
if (record.before !== undefined) added.push(record.before)
|
|
3104
3725
|
added.push(record.box)
|
|
3105
3726
|
if (record.after !== undefined) added.push(record.after)
|
|
3106
3727
|
}
|
|
3728
|
+
const totalRows = previous.totalRows + deltaRows
|
|
3729
|
+
const needsTrim = rowCap > 0 && totalRows > rowCap + Math.floor(rowCap / 4)
|
|
3107
3730
|
return {
|
|
3108
3731
|
cache: {
|
|
3109
3732
|
entries: previous.entries.concat(suffix),
|
|
@@ -3112,9 +3735,13 @@ export function computeSettledRows(
|
|
|
3112
3735
|
resumed: previous.resumed,
|
|
3113
3736
|
showReasoning,
|
|
3114
3737
|
epoch: previous.epoch,
|
|
3738
|
+
columns: previous.columns,
|
|
3115
3739
|
flat: previous.flat.concat(added),
|
|
3740
|
+
droppedEntries: previous.droppedEntries,
|
|
3741
|
+
totalRows,
|
|
3742
|
+
needsTrim,
|
|
3116
3743
|
},
|
|
3117
|
-
built:
|
|
3744
|
+
built: suffix.length,
|
|
3118
3745
|
}
|
|
3119
3746
|
}
|
|
3120
3747
|
|
|
@@ -3137,12 +3764,13 @@ export function App(props: AppProps): ReactElement {
|
|
|
3137
3764
|
const skills = useSyncExternalStore(props.skills.subscribe, readSkills)
|
|
3138
3765
|
const [modelLabel, setModelLabel] = useState(props.model)
|
|
3139
3766
|
const [modelOpen, setModelOpen] = useState(false)
|
|
3140
|
-
/** Nested /model stages; only one owns terminal input at a time. */
|
|
3141
|
-
const [providerOpen, setProviderOpen] = useState(false)
|
|
3142
|
-
const [providerAction, setProviderAction] = useState<
|
|
3143
|
-
kind: 'credential' | 'unset' | 'remove'
|
|
3144
|
-
target: ProviderTargetView
|
|
3145
|
-
|
|
3767
|
+
/** Nested /model stages; only one owns terminal input at a time. */
|
|
3768
|
+
const [providerOpen, setProviderOpen] = useState(false)
|
|
3769
|
+
const [providerAction, setProviderAction] = useState<
|
|
3770
|
+
| { kind: 'credential' | 'configure' | 'unset' | 'remove'; target: ProviderTargetView }
|
|
3771
|
+
| { kind: 'login' | 'logout'; target: ProviderTargetView; authorization: ProviderAuthorizationRow }
|
|
3772
|
+
| undefined
|
|
3773
|
+
>(undefined)
|
|
3146
3774
|
/** The model row whose effort levels the /model stage lists; undefined shows the model list. */
|
|
3147
3775
|
const [effortFor, setEffortFor] = useState<ModelRow | undefined>(undefined)
|
|
3148
3776
|
/** Effective reasoning effort, shown in the /model picker and switch notice. */
|
|
@@ -3156,7 +3784,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
3156
3784
|
* follows the applied model label (what the status bar actually shows),
|
|
3157
3785
|
* never the initial paint, and the tier is derived from the label and
|
|
3158
3786
|
* cached at the switch. The 33ms tick itself lives inside Input, so the
|
|
3159
|
-
* sweep re-renders only the composer
|
|
3787
|
+
* sweep re-renders only the composer band, not the whole tree, at 30fps;
|
|
3160
3788
|
* App owns the rarely-changing tier/style and Input starts the sweep
|
|
3161
3789
|
* whenever that pair changes. */
|
|
3162
3790
|
const [waveTier, setWaveTier] = useState<DeepseekWaveTier | null>(null)
|
|
@@ -3192,8 +3820,10 @@ export function App(props: AppProps): ReactElement {
|
|
|
3192
3820
|
}, [modelLabel, effortLabel])
|
|
3193
3821
|
const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
|
|
3194
3822
|
const [modelError, setModelError] = useState<string | undefined>(undefined)
|
|
3195
|
-
const [providerDirectory, setProviderDirectory] = useState<ProviderSettingsDirectory | undefined>(undefined)
|
|
3196
|
-
const [providerError, setProviderError] = useState<string | undefined>(undefined)
|
|
3823
|
+
const [providerDirectory, setProviderDirectory] = useState<ProviderSettingsDirectory | undefined>(undefined)
|
|
3824
|
+
const [providerError, setProviderError] = useState<string | undefined>(undefined)
|
|
3825
|
+
const [authorizationDirectory, setAuthorizationDirectory] = useState<ProviderAuthorizationDirectory | undefined>(undefined)
|
|
3826
|
+
const [authorizationError, setAuthorizationError] = useState<string | undefined>(undefined)
|
|
3197
3827
|
const [modelLoadEpoch, setModelLoadEpoch] = useState(0)
|
|
3198
3828
|
const [notice, setNotice] = useState<{ text: string; tone: NoticeTone } | undefined>(undefined)
|
|
3199
3829
|
const notify = useCallback((text: string, tone: NoticeTone = 'info'): void => {
|
|
@@ -3233,7 +3863,21 @@ export function App(props: AppProps): ReactElement {
|
|
|
3233
3863
|
return () => {
|
|
3234
3864
|
cancelled = true
|
|
3235
3865
|
}
|
|
3236
|
-
}, [modelOpen, modelLoadEpoch, props.loadModelProviders])
|
|
3866
|
+
}, [modelOpen, modelLoadEpoch, props.loadModelProviders])
|
|
3867
|
+
useEffect(() => {
|
|
3868
|
+
if (!modelOpen || props.loadProviderAuthorizations === undefined) return
|
|
3869
|
+
let cancelled = false
|
|
3870
|
+
setAuthorizationDirectory(undefined)
|
|
3871
|
+
setAuthorizationError(undefined)
|
|
3872
|
+
Promise.resolve().then(() => props.loadProviderAuthorizations!()).then((loaded) => {
|
|
3873
|
+
if (!cancelled) setAuthorizationDirectory(loaded)
|
|
3874
|
+
}, (error: unknown) => {
|
|
3875
|
+
if (!cancelled) setAuthorizationError(error instanceof Error ? error.message : String(error))
|
|
3876
|
+
})
|
|
3877
|
+
return () => {
|
|
3878
|
+
cancelled = true
|
|
3879
|
+
}
|
|
3880
|
+
}, [modelOpen, modelLoadEpoch, props.loadProviderAuthorizations])
|
|
3237
3881
|
useEffect(() => {
|
|
3238
3882
|
const subscribe = props.subscribeModelProviders
|
|
3239
3883
|
if (!modelOpen || subscribe === undefined) return
|
|
@@ -3242,23 +3886,35 @@ export function App(props: AppProps): ReactElement {
|
|
|
3242
3886
|
} catch (error: unknown) {
|
|
3243
3887
|
setProviderError(error instanceof Error ? error.message : String(error))
|
|
3244
3888
|
}
|
|
3245
|
-
}, [modelOpen, props.subscribeModelProviders])
|
|
3889
|
+
}, [modelOpen, props.subscribeModelProviders])
|
|
3890
|
+
useEffect(() => {
|
|
3891
|
+
const subscribe = props.subscribeProviderAuthorizations
|
|
3892
|
+
if (!modelOpen || subscribe === undefined) return
|
|
3893
|
+
try {
|
|
3894
|
+
return subscribe(() => setModelLoadEpoch(epoch => epoch + 1))
|
|
3895
|
+
} catch (error: unknown) {
|
|
3896
|
+
setAuthorizationError(error instanceof Error ? error.message : String(error))
|
|
3897
|
+
}
|
|
3898
|
+
}, [modelOpen, props.subscribeProviderAuthorizations])
|
|
3246
3899
|
|
|
3247
3900
|
const busy = view.busy
|
|
3248
3901
|
const [showReasoning, setShowReasoning] = useState(false)
|
|
3249
3902
|
const [verboseOpen, setVerboseOpen] = useState(false)
|
|
3903
|
+
const [diffView, setDiffView] = useState<GitDiffView | undefined>(undefined)
|
|
3250
3904
|
const [helpOpen, setHelpOpen] = useState(false)
|
|
3251
3905
|
const [modeOpen, setModeOpen] = useState(false)
|
|
3252
3906
|
const [permissionOpen, setPermissionOpen] = useState(false)
|
|
3253
3907
|
const [resumeOpen, setResumeOpen] = useState(false)
|
|
3254
3908
|
const [pluginOpen, setPluginOpen] = useState(false)
|
|
3255
3909
|
const [pluginQuery, setPluginQuery] = useState('')
|
|
3910
|
+
const [jobsOpen, setJobsOpen] = useState(false)
|
|
3256
3911
|
const [statuslineOpen, setStatuslineOpen] = useState(false)
|
|
3257
3912
|
const [statuslineItems, setStatuslineItems] = useState<readonly StatusItemId[]>(() => parseStatuslineItems(props.statusline))
|
|
3258
3913
|
const [themeOpen, setThemeOpen] = useState(false)
|
|
3259
3914
|
const [historyOpen, setHistoryOpen] = useState(false)
|
|
3260
3915
|
const [agentsOpen, setAgentsOpen] = useState(false)
|
|
3261
3916
|
const [subagentOpen, setSubagentOpen] = useState(false)
|
|
3917
|
+
const [todosOpen, setTodosOpen] = useState(false)
|
|
3262
3918
|
/** /delete state: delete-mode hint plus an optional pre-armed row id. */
|
|
3263
3919
|
const [resumeDelete, setResumeDelete] = useState<{ mode: boolean; id?: string }>({ mode: false })
|
|
3264
3920
|
/** The row id awaiting y/n in the COMPOSER (codex delete confirm): the
|
|
@@ -3330,7 +3986,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
3330
3986
|
// panel keypress.
|
|
3331
3987
|
const inputActive = deleteConfirmId !== undefined
|
|
3332
3988
|
? !approvalPending && !questionPending
|
|
3333
|
-
: !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !verboseOpen && !approvalPending && !questionPending
|
|
3989
|
+
: !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !jobsOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !todosOpen && !verboseOpen && diffView === undefined && !approvalPending && !questionPending
|
|
3334
3990
|
|
|
3335
3991
|
// Human questions outrank local inspectors. Close the lower modal instead
|
|
3336
3992
|
// of leaving an approval/question visible but keyboard-locked behind it.
|
|
@@ -3350,8 +4006,10 @@ export function App(props: AppProps): ReactElement {
|
|
|
3350
4006
|
setHistoryOpen(false)
|
|
3351
4007
|
setAgentsOpen(false)
|
|
3352
4008
|
setSubagentOpen(false)
|
|
4009
|
+
setTodosOpen(false)
|
|
3353
4010
|
setDeleteConfirmId(undefined)
|
|
3354
4011
|
setVerboseOpen(false)
|
|
4012
|
+
setDiffView(undefined)
|
|
3355
4013
|
}, [approvalPending, questionPending])
|
|
3356
4014
|
|
|
3357
4015
|
// Append-only transcript: everything up to the first still-mutable entry
|
|
@@ -3366,9 +4024,17 @@ export function App(props: AppProps): ReactElement {
|
|
|
3366
4024
|
// settled prefix is permanently final, so a grown boundary builds ONLY the
|
|
3367
4025
|
// newly settled suffix and reuses every cached element — long histories
|
|
3368
4026
|
// stop re-creating rows (and re-parsing MarkdownBody) on every durable
|
|
3369
|
-
// event. A source-backed replay (`refreshEpoch` bump: resize / Ctrl+L
|
|
3370
|
-
//
|
|
4027
|
+
// event. A source-backed replay (`refreshEpoch` bump: resize / Ctrl+L)
|
|
4028
|
+
// rebuilds the CURRENT row set from index 0,
|
|
3371
4029
|
// so the replay stays complete and never ghosts a pending/running tail.
|
|
4030
|
+
// Hook order is unconditional. Its dimensions drive every live-region
|
|
4031
|
+
// budget before any dynamic rows are constructed.
|
|
4032
|
+
const appStdout = useStdout().stdout
|
|
4033
|
+
const [terminalSize, setTerminalSize] = useState(() => ({
|
|
4034
|
+
columns: appStdout?.columns ?? 80,
|
|
4035
|
+
rows: appStdout?.rows ?? 30,
|
|
4036
|
+
}))
|
|
4037
|
+
const terminalSizeRef = useRef(terminalSize)
|
|
3372
4038
|
const settledRowsCache = useRef<SettledRowsCache | undefined>(undefined)
|
|
3373
4039
|
const settledRows = useMemo(() => {
|
|
3374
4040
|
const result = computeSettledRows(
|
|
@@ -3378,19 +4044,15 @@ export function App(props: AppProps): ReactElement {
|
|
|
3378
4044
|
showReasoning,
|
|
3379
4045
|
props.resumed,
|
|
3380
4046
|
refreshEpoch,
|
|
4047
|
+
terminalSize.columns,
|
|
3381
4048
|
)
|
|
3382
4049
|
settledRowsCache.current = result.cache
|
|
3383
4050
|
return result.cache.flat
|
|
3384
|
-
}, [view.entries, settled, showReasoning, props.resumed, refreshEpoch])
|
|
4051
|
+
}, [view.entries, settled, showReasoning, props.resumed, refreshEpoch, terminalSize.columns])
|
|
3385
4052
|
|
|
3386
|
-
//
|
|
3387
|
-
//
|
|
3388
|
-
const
|
|
3389
|
-
const [terminalSize, setTerminalSize] = useState(() => ({
|
|
3390
|
-
columns: appStdout?.columns ?? 80,
|
|
3391
|
-
rows: appStdout?.rows ?? 30,
|
|
3392
|
-
}))
|
|
3393
|
-
const terminalSizeRef = useRef(terminalSize)
|
|
4053
|
+
// One pending synchronized frame covers a debounced resize or explicit
|
|
4054
|
+
// source-backed replay. It is closed after the corresponding React commit.
|
|
4055
|
+
const synchronizedReplayPending = useRef(false)
|
|
3394
4056
|
useEffect(() => {
|
|
3395
4057
|
if (appStdout === undefined) return
|
|
3396
4058
|
let replayTimer: ReturnType<typeof setTimeout> | undefined
|
|
@@ -3411,7 +4073,8 @@ export function App(props: AppProps): ReactElement {
|
|
|
3411
4073
|
setTerminalSize(next)
|
|
3412
4074
|
if (replayTimer !== undefined) clearTimeout(replayTimer)
|
|
3413
4075
|
replayTimer = setTimeout(() => {
|
|
3414
|
-
|
|
4076
|
+
synchronizedReplayPending.current = true
|
|
4077
|
+
appStdout.write(SYNCHRONIZED_UPDATE_BEGIN + RESIZE_REFLOW_CLEAR)
|
|
3415
4078
|
setRefreshEpoch(epoch => epoch + 1)
|
|
3416
4079
|
}, RESIZE_REFLOW_DELAY_MS)
|
|
3417
4080
|
}
|
|
@@ -3424,16 +4087,30 @@ export function App(props: AppProps): ReactElement {
|
|
|
3424
4087
|
const terminalRows = terminalSize.rows
|
|
3425
4088
|
const terminalColumns = terminalSize.columns
|
|
3426
4089
|
const composerGutterRows = layoutGutterRows(terminalRows)
|
|
3427
|
-
//
|
|
3428
|
-
// keeps the live/streaming
|
|
3429
|
-
|
|
4090
|
+
// The composer's live row count, reported one-way by the editor itself
|
|
4091
|
+
// (frozen modals report 1). This keeps the live/streaming budget exact as
|
|
4092
|
+
// a multiline draft grows, without lifting any editor state into the App.
|
|
4093
|
+
const [composerRows, setComposerRows] = useState(1)
|
|
4094
|
+
const handleEditorRows = useCallback((rows: number): void => {
|
|
4095
|
+
setComposerRows(current => (current === rows ? current : rows))
|
|
4096
|
+
}, [])
|
|
4097
|
+
const composerEditorCap = composerMaxRows(terminalRows)
|
|
4098
|
+
// Bottom chrome is composer (2 borders + composerRows) + menu + status
|
|
4099
|
+
// (up to 2 rows); the budget keeps the live/streaming area strictly below
|
|
4100
|
+
// the terminal height as the editor grows.
|
|
4101
|
+
const dynamicRows = Math.max(1, terminalRows - 13 - composerGutterRows - (composerRows - 1))
|
|
3430
4102
|
const streamingActive = view.streaming !== '' || view.streamingReasoning !== ''
|
|
3431
4103
|
const deepDivingVisible = busy && !streamingActive
|
|
3432
4104
|
const allLiveLines = useMemo(
|
|
3433
|
-
() => view.entries.slice(settled).flatMap(
|
|
3434
|
-
|
|
4105
|
+
() => view.entries.slice(settled).flatMap(
|
|
4106
|
+
entry => transcriptEntryLines(entry, Math.max(10, terminalColumns - 2), showReasoning),
|
|
4107
|
+
),
|
|
4108
|
+
[view.entries, settled, terminalColumns, showReasoning],
|
|
3435
4109
|
)
|
|
3436
|
-
|
|
4110
|
+
// Reserve the same stream slice from the moment a turn becomes busy. This
|
|
4111
|
+
// keeps the first thinking frame from changing the dynamic-tree geometry
|
|
4112
|
+
// underneath Ink's cursor ledger and avoids a start-of-thinking flash.
|
|
4113
|
+
const liveBudget = busy || streamingActive
|
|
3437
4114
|
? Math.max(1, Math.floor(dynamicRows / 3))
|
|
3438
4115
|
: Math.max(0, dynamicRows - (deepDivingVisible ? 1 : 0))
|
|
3439
4116
|
const visibleLiveLines = liveBudget === 0 ? [] : allLiveLines.slice(-liveBudget)
|
|
@@ -3454,24 +4131,58 @@ export function App(props: AppProps): ReactElement {
|
|
|
3454
4131
|
? Math.max(1, Math.floor(streamRows / 3))
|
|
3455
4132
|
: 1
|
|
3456
4133
|
const answerRows = view.streaming === '' ? 0 : Math.max(1, streamRows - reasoningRows)
|
|
3457
|
-
const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !verboseOpen && !approvalPending && !questionPending
|
|
4134
|
+
const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !jobsOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !todosOpen && !verboseOpen && diffView === undefined && !approvalPending && !questionPending
|
|
3458
4135
|
const inspectorVisible = verboseOpen && !approvalPending && !questionPending
|
|
3459
|
-
const modalVisible = modelOpen || helpOpen || modeOpen || permissionOpen || resumeOpen || pluginOpen || statuslineOpen || themeOpen || historyOpen || agentsOpen || subagentOpen || inspectorVisible || approvalPending || questionPending
|
|
4136
|
+
const modalVisible = modelOpen || helpOpen || modeOpen || permissionOpen || resumeOpen || pluginOpen || jobsOpen || statuslineOpen || themeOpen || historyOpen || agentsOpen || subagentOpen || todosOpen || inspectorVisible || diffView !== undefined || approvalPending || questionPending
|
|
3460
4137
|
const closeInspector = useCallback((): void => {
|
|
3461
4138
|
setVerboseOpen(false)
|
|
3462
4139
|
}, [])
|
|
3463
4140
|
const refreshScreen = (): void => {
|
|
3464
|
-
|
|
4141
|
+
// Same source-backed clear the resize path uses: reset the scroll region
|
|
4142
|
+
// (`\x1b[r`) before wiping screen AND scrollback, then home the cursor.
|
|
4143
|
+
// A bare `\x1b[2J\x1b[3J\x1b[H` leaves a previously set scroll region in
|
|
4144
|
+
// place, so Ink's next repaint positions against stale bounds — the
|
|
4145
|
+
// stale-position flicker where the screen keeps redrawing.
|
|
4146
|
+
if (appStdout !== undefined) {
|
|
4147
|
+
synchronizedReplayPending.current = true
|
|
4148
|
+
appStdout.write(SYNCHRONIZED_UPDATE_BEGIN + RESIZE_REFLOW_CLEAR)
|
|
4149
|
+
}
|
|
3465
4150
|
setRefreshEpoch(epoch => epoch + 1)
|
|
3466
4151
|
}
|
|
4152
|
+
useEffect(() => {
|
|
4153
|
+
if (!synchronizedReplayPending.current || appStdout === undefined) return
|
|
4154
|
+
synchronizedReplayPending.current = false
|
|
4155
|
+
appStdout.write(SYNCHRONIZED_UPDATE_END)
|
|
4156
|
+
}, [appStdout, refreshEpoch])
|
|
4157
|
+
// An idle Ctrl+R fold toggle joins resize and explicit Ctrl+L as a deliberate
|
|
4158
|
+
// source-backed rebuild of native scrollback; busy turns never do.
|
|
3467
4159
|
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
4160
|
+
// Rendered-history cap: when the settled window overflows the trim
|
|
4161
|
+
// hysteresis, one source-backed replay re-windows it (the rebuild branch
|
|
4162
|
+
// drops the oldest entries beyond the cap). Deferred while busy or
|
|
4163
|
+
// streaming so the clear never interrupts a visible stream; the flag
|
|
4164
|
+
// survives until the turn calms.
|
|
4165
|
+
const settledNeedsTrim = settledRowsCache.current?.needsTrim === true
|
|
4166
|
+
useEffect(() => {
|
|
4167
|
+
if (!settledNeedsTrim || busy || streamingActive) return
|
|
4168
|
+
refreshScreen()
|
|
4169
|
+
}, [settledNeedsTrim, busy, streamingActive])
|
|
4170
|
+
|
|
4171
|
+
const sessionHasImages = useMemo(() => view.entries.some(entry =>
|
|
4172
|
+
(entry.kind === 'user' || entry.kind === 'pending') && (entry.images?.length ?? 0) > 0), [view.entries])
|
|
4173
|
+
|
|
4174
|
+
/** Apply one /model pick: record the selection, close the panel, report via notice. */
|
|
4175
|
+
const applyModel = (row: ModelRow, effortId: string | undefined): void => {
|
|
4176
|
+
try {
|
|
4177
|
+
const label = props.selectModel(row, effortId)
|
|
4178
|
+
setModelLabel(label)
|
|
4179
|
+
setEffortLabel(effortId)
|
|
4180
|
+
const selected = `${label}${effortId === undefined || effortId === '' ? '' : `@${effortId}`}`
|
|
4181
|
+
if (sessionHasImages && row.inputModalities !== undefined && !row.inputModalities.includes('image')) {
|
|
4182
|
+
notify(`model → ${selected} · image history will be sent as text placeholders`, 'warning')
|
|
4183
|
+
} else {
|
|
4184
|
+
notify(`model → next step uses ${selected}`)
|
|
4185
|
+
}
|
|
3475
4186
|
setModelOpen(false)
|
|
3476
4187
|
setProviderOpen(false)
|
|
3477
4188
|
setProviderAction(undefined)
|
|
@@ -3491,8 +4202,58 @@ export function App(props: AppProps): ReactElement {
|
|
|
3491
4202
|
setEffortFor(undefined)
|
|
3492
4203
|
}
|
|
3493
4204
|
let modelSurface: ReactElement | undefined
|
|
3494
|
-
if (modelOpen && !approvalPending && !questionPending) {
|
|
3495
|
-
if (providerAction?.kind === '
|
|
4205
|
+
if (modelOpen && !approvalPending && !questionPending) {
|
|
4206
|
+
if (providerAction?.kind === 'login'
|
|
4207
|
+
&& props.beginProviderAuthorization !== undefined
|
|
4208
|
+
&& props.cancelProviderAuthorization !== undefined
|
|
4209
|
+
&& props.openAuthorizationUrl !== undefined
|
|
4210
|
+
&& props.copyTextValue !== undefined) {
|
|
4211
|
+
modelSurface = createElement(ProviderAuthorizationPanel, {
|
|
4212
|
+
row: providerAction.authorization,
|
|
4213
|
+
begin: props.beginProviderAuthorization,
|
|
4214
|
+
cancel: () => props.cancelProviderAuthorization!(providerAction.authorization),
|
|
4215
|
+
openUrl: props.openAuthorizationUrl,
|
|
4216
|
+
copy: props.copyTextValue,
|
|
4217
|
+
done: () => {
|
|
4218
|
+
const authorization = providerAction.authorization
|
|
4219
|
+
setProviderAction(undefined)
|
|
4220
|
+
setProviderOpen(false)
|
|
4221
|
+
reloadModelSurfaces()
|
|
4222
|
+
notify(`logged in to ${authorization.label}; select a model`)
|
|
4223
|
+
},
|
|
4224
|
+
back: () => {
|
|
4225
|
+
setProviderAction(undefined)
|
|
4226
|
+
setProviderOpen(true)
|
|
4227
|
+
},
|
|
4228
|
+
})
|
|
4229
|
+
} else if (providerAction?.kind === 'logout' && props.logoutProviderAuthorization !== undefined) {
|
|
4230
|
+
modelSurface = createElement(ProviderAuthorizationLogoutPanel, {
|
|
4231
|
+
row: providerAction.authorization,
|
|
4232
|
+
confirm: props.logoutProviderAuthorization,
|
|
4233
|
+
done: () => {
|
|
4234
|
+
const authorization = providerAction.authorization
|
|
4235
|
+
setProviderAction(undefined)
|
|
4236
|
+
setProviderOpen(true)
|
|
4237
|
+
reloadModelSurfaces()
|
|
4238
|
+
notify(`logged out from ${authorization.label}`)
|
|
4239
|
+
},
|
|
4240
|
+
back: () => setProviderAction(undefined),
|
|
4241
|
+
})
|
|
4242
|
+
} else if (providerAction?.kind === 'configure' && props.saveModelProviderConfiguration !== undefined) {
|
|
4243
|
+
modelSurface = createElement(ProviderConfigurationPanel, {
|
|
4244
|
+
target: providerAction.target,
|
|
4245
|
+
catalog: directory?.rows ?? [],
|
|
4246
|
+
save: props.saveModelProviderConfiguration,
|
|
4247
|
+
done: () => {
|
|
4248
|
+
const target = providerAction.target
|
|
4249
|
+
setProviderAction(undefined)
|
|
4250
|
+
setProviderOpen(true)
|
|
4251
|
+
reloadModelSurfaces()
|
|
4252
|
+
notify(`provider configuration saved: ${target.displayName}`)
|
|
4253
|
+
},
|
|
4254
|
+
back: () => setProviderAction(undefined),
|
|
4255
|
+
})
|
|
4256
|
+
} else if (providerAction?.kind === 'credential' && props.saveModelProviderCredential !== undefined) {
|
|
3496
4257
|
modelSurface = createElement(ProviderCredentialPanel, {
|
|
3497
4258
|
target: providerAction.target,
|
|
3498
4259
|
save: props.saveModelProviderCredential,
|
|
@@ -3534,9 +4295,11 @@ export function App(props: AppProps): ReactElement {
|
|
|
3534
4295
|
back: () => setProviderAction(undefined),
|
|
3535
4296
|
})
|
|
3536
4297
|
} else if (providerOpen) {
|
|
3537
|
-
modelSurface = createElement(ProviderPanel, {
|
|
3538
|
-
directory: providerDirectory,
|
|
3539
|
-
error: providerError,
|
|
4298
|
+
modelSurface = createElement(ProviderPanel, {
|
|
4299
|
+
directory: providerDirectory,
|
|
4300
|
+
error: providerError,
|
|
4301
|
+
authorizations: authorizationDirectory,
|
|
4302
|
+
authorizationError,
|
|
3540
4303
|
onCredential: (target: ProviderTargetView) => {
|
|
3541
4304
|
if (props.saveModelProviderCredential === undefined) {
|
|
3542
4305
|
notify('API key storage is unavailable in this profile', 'warning')
|
|
@@ -3544,6 +4307,13 @@ export function App(props: AppProps): ReactElement {
|
|
|
3544
4307
|
}
|
|
3545
4308
|
setProviderAction({ kind: 'credential', target })
|
|
3546
4309
|
},
|
|
4310
|
+
onConfigure: (target: ProviderTargetView) => {
|
|
4311
|
+
if (props.saveModelProviderConfiguration === undefined) {
|
|
4312
|
+
notify('provider configuration is unavailable in this profile', 'warning')
|
|
4313
|
+
return
|
|
4314
|
+
}
|
|
4315
|
+
setProviderAction({ kind: 'configure', target })
|
|
4316
|
+
},
|
|
3547
4317
|
onUnset: (target: ProviderTargetView) => {
|
|
3548
4318
|
if (props.unsetModelProviderCredential === undefined) {
|
|
3549
4319
|
notify('API key removal is unavailable in this profile', 'warning')
|
|
@@ -3551,13 +4321,34 @@ export function App(props: AppProps): ReactElement {
|
|
|
3551
4321
|
}
|
|
3552
4322
|
setProviderAction({ kind: 'unset', target })
|
|
3553
4323
|
},
|
|
3554
|
-
onRemove: (target: ProviderTargetView) => {
|
|
4324
|
+
onRemove: (target: ProviderTargetView) => {
|
|
3555
4325
|
if (props.removeModelProvider === undefined) {
|
|
3556
4326
|
notify('provider removal is unavailable in this profile', 'warning')
|
|
3557
4327
|
return
|
|
3558
4328
|
}
|
|
3559
|
-
setProviderAction({ kind: 'remove', target })
|
|
3560
|
-
},
|
|
4329
|
+
setProviderAction({ kind: 'remove', target })
|
|
4330
|
+
},
|
|
4331
|
+
onLogin: (target: ProviderTargetView, authorization: ProviderAuthorizationRow) => {
|
|
4332
|
+
if (busy) {
|
|
4333
|
+
notify('provider login is available only while the agent is idle', 'warning')
|
|
4334
|
+
return
|
|
4335
|
+
}
|
|
4336
|
+
if (props.beginProviderAuthorization === undefined
|
|
4337
|
+
|| props.cancelProviderAuthorization === undefined
|
|
4338
|
+
|| props.openAuthorizationUrl === undefined
|
|
4339
|
+
|| props.copyTextValue === undefined) {
|
|
4340
|
+
notify('provider login is unavailable in this profile', 'warning')
|
|
4341
|
+
return
|
|
4342
|
+
}
|
|
4343
|
+
setProviderAction({ kind: 'login', target, authorization })
|
|
4344
|
+
},
|
|
4345
|
+
onLogout: (target: ProviderTargetView, authorization: ProviderAuthorizationRow) => {
|
|
4346
|
+
if (props.logoutProviderAuthorization === undefined) {
|
|
4347
|
+
notify('provider logout is unavailable in this profile', 'warning')
|
|
4348
|
+
return
|
|
4349
|
+
}
|
|
4350
|
+
setProviderAction({ kind: 'logout', target, authorization })
|
|
4351
|
+
},
|
|
3561
4352
|
onRetry: reloadModelSurfaces,
|
|
3562
4353
|
onBack: () => setProviderOpen(false),
|
|
3563
4354
|
})
|
|
@@ -3606,13 +4397,14 @@ export function App(props: AppProps): ReactElement {
|
|
|
3606
4397
|
transcriptVisible
|
|
3607
4398
|
? createElement(
|
|
3608
4399
|
Box,
|
|
3609
|
-
//
|
|
3610
|
-
//
|
|
3611
|
-
|
|
4400
|
+
// No container padding: every live row carries its own two-column
|
|
4401
|
+
// prefix, so streaming text lands exactly where the composer's input
|
|
4402
|
+
// text and the settled reply both render (Codex LIVE_PREFIX).
|
|
4403
|
+
{ flexDirection: 'column' },
|
|
3612
4404
|
visibleLiveLines.length === 0 ? undefined : createElement(StyledRows, { lines: visibleLiveLines }),
|
|
3613
4405
|
view.streamingReasoning !== '' && reasoningRows > 0
|
|
3614
4406
|
? createElement(StreamTail, {
|
|
3615
|
-
text: showReasoning ? view.streamingReasoning : 'Thinking…',
|
|
4407
|
+
text: showReasoning ? view.streamingReasoning : 'Thinking… (Ctrl+R to expand)',
|
|
3616
4408
|
prefix: '✻ ',
|
|
3617
4409
|
continuationPrefix: ' ',
|
|
3618
4410
|
dim: true,
|
|
@@ -3633,6 +4425,14 @@ export function App(props: AppProps): ReactElement {
|
|
|
3633
4425
|
: undefined,
|
|
3634
4426
|
transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : undefined,
|
|
3635
4427
|
transcriptVisible ? createElement(AgentsLine, { rows: agentRows }) : undefined,
|
|
4428
|
+
todosOpen && !approvalPending && !questionPending
|
|
4429
|
+
? createElement(MemoTodoListPanel, {
|
|
4430
|
+
todos: view.todos,
|
|
4431
|
+
onClose: () => {
|
|
4432
|
+
setTodosOpen(false)
|
|
4433
|
+
},
|
|
4434
|
+
})
|
|
4435
|
+
: undefined,
|
|
3636
4436
|
createElement(QuestionBar, { store: props.questions, snapshot: questionSnapshot, locked: false }),
|
|
3637
4437
|
createElement(ApprovalBar, { snapshot: approvalSnapshot, locked: questionPending, notify }),
|
|
3638
4438
|
modelSurface,
|
|
@@ -3647,6 +4447,12 @@ export function App(props: AppProps): ReactElement {
|
|
|
3647
4447
|
},
|
|
3648
4448
|
})
|
|
3649
4449
|
: undefined,
|
|
4450
|
+
diffView !== undefined && !approvalPending && !questionPending
|
|
4451
|
+
? createElement(DiffPanel, {
|
|
4452
|
+
view: diffView,
|
|
4453
|
+
onClose: () => setDiffView(undefined),
|
|
4454
|
+
})
|
|
4455
|
+
: undefined,
|
|
3650
4456
|
verboseOpen && !approvalPending && !questionPending
|
|
3651
4457
|
? createElement(MemoVerbosePanel, {
|
|
3652
4458
|
entries: view.entries,
|
|
@@ -3698,6 +4504,9 @@ export function App(props: AppProps): ReactElement {
|
|
|
3698
4504
|
pluginOpen && !approvalPending && !questionPending
|
|
3699
4505
|
? createElement(PluginPanel, { load: props.loadPlugins, initialQuery: pluginQuery, close: () => setPluginOpen(false) })
|
|
3700
4506
|
: undefined,
|
|
4507
|
+
jobsOpen && !approvalPending && !questionPending
|
|
4508
|
+
? createElement(JobsPanel, { load: props.loadJobs, close: () => setJobsOpen(false) })
|
|
4509
|
+
: undefined,
|
|
3701
4510
|
statuslineOpen && !approvalPending && !questionPending
|
|
3702
4511
|
? createElement(StatuslinePanel, {
|
|
3703
4512
|
enabled: statuslineItems,
|
|
@@ -3790,8 +4599,10 @@ export function App(props: AppProps): ReactElement {
|
|
|
3790
4599
|
openModel: () => {
|
|
3791
4600
|
setDirectory(undefined)
|
|
3792
4601
|
setModelError(undefined)
|
|
3793
|
-
setProviderDirectory(undefined)
|
|
3794
|
-
setProviderError(undefined)
|
|
4602
|
+
setProviderDirectory(undefined)
|
|
4603
|
+
setProviderError(undefined)
|
|
4604
|
+
setAuthorizationDirectory(undefined)
|
|
4605
|
+
setAuthorizationError(undefined)
|
|
3795
4606
|
setProviderOpen(false)
|
|
3796
4607
|
setProviderAction(undefined)
|
|
3797
4608
|
setEffortFor(undefined)
|
|
@@ -3844,21 +4655,30 @@ export function App(props: AppProps): ReactElement {
|
|
|
3844
4655
|
openPermission: () => setPermissionOpen(true),
|
|
3845
4656
|
openResume: () => { setResumeDelete({ mode: false }); setResumeOpen(true) },
|
|
3846
4657
|
openPlugin: (query = '') => { setPluginQuery(query); setPluginOpen(true) },
|
|
4658
|
+
openJobs: () => setJobsOpen(true),
|
|
3847
4659
|
openStatusline: () => setStatuslineOpen(true),
|
|
3848
4660
|
openTheme: () => setThemeOpen(true),
|
|
3849
4661
|
openHistory: () => setHistoryOpen(true),
|
|
3850
4662
|
openAgents: () => setAgentsOpen(true),
|
|
3851
4663
|
openSubagent: () => setSubagentOpen(true),
|
|
4664
|
+
openTodos: () => setTodosOpen(true),
|
|
3852
4665
|
openDelete: (id?: string) => {
|
|
3853
4666
|
const armed = id === undefined || id === '' ? undefined : id
|
|
3854
4667
|
setResumeDelete({ mode: true, ...armed === undefined ? {} : { id: armed } })
|
|
3855
4668
|
setDeleteConfirmId(armed)
|
|
3856
4669
|
setResumeOpen(true)
|
|
3857
4670
|
},
|
|
4671
|
+
openDiff: (argument: string) => {
|
|
4672
|
+
void props.loadGitDiff(argument).then(setDiffView, (error: unknown) => {
|
|
4673
|
+
notify(`diff failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
4674
|
+
})
|
|
4675
|
+
},
|
|
4676
|
+
reviewChanges: props.reviewChanges,
|
|
3858
4677
|
deleteConfirm: deleteConfirmId,
|
|
3859
4678
|
confirmDelete,
|
|
3860
4679
|
cancelDelete,
|
|
3861
4680
|
createSession: props.createSession,
|
|
4681
|
+
forkSession: props.forkSession,
|
|
3862
4682
|
cancelSessionSwitch: props.cancelSessionSwitch,
|
|
3863
4683
|
notify,
|
|
3864
4684
|
hasNotice: notice !== undefined,
|
|
@@ -3872,18 +4692,23 @@ export function App(props: AppProps): ReactElement {
|
|
|
3872
4692
|
props.store.reset()
|
|
3873
4693
|
},
|
|
3874
4694
|
refresh: refreshScreen,
|
|
3875
|
-
// Ctrl+R
|
|
3876
|
-
//
|
|
3877
|
-
//
|
|
3878
|
-
//
|
|
4695
|
+
// Ctrl+R flips the reasoning fold. Idle toggles must be visible: rows
|
|
4696
|
+
// already emitted through Static are native scrollback, so the fold
|
|
4697
|
+
// state of past entries can only change through the source-backed
|
|
4698
|
+
// replay (one clear + rebuild, wrapped in a synchronized frame). A
|
|
4699
|
+
// busy/streaming turn stays calm: the live region flips alone and the
|
|
4700
|
+
// entries that settle afterward capture the mode.
|
|
3879
4701
|
toggleReasoning: () => {
|
|
3880
4702
|
setShowReasoning(current => !current)
|
|
3881
|
-
refreshScreen()
|
|
4703
|
+
if (!busy && !streamingActive) refreshScreen()
|
|
3882
4704
|
},
|
|
3883
|
-
loadMentions: props.loadMentions,
|
|
4705
|
+
loadMentions: props.loadMentions,
|
|
4706
|
+
inspectImages: props.inspectImages,
|
|
4707
|
+
prepareImages: props.prepareImages,
|
|
3884
4708
|
cyclePermission: props.cyclePermission,
|
|
3885
4709
|
exportTranscript: props.exportTranscript,
|
|
3886
4710
|
renameTitle: props.renameTitle,
|
|
4711
|
+
copyLastResponse: props.copyLastResponse,
|
|
3887
4712
|
recallSpace,
|
|
3888
4713
|
recordLocal,
|
|
3889
4714
|
recordHistory: props.recordHistory,
|
|
@@ -3893,6 +4718,8 @@ export function App(props: AppProps): ReactElement {
|
|
|
3893
4718
|
historyConsumed,
|
|
3894
4719
|
waveTier,
|
|
3895
4720
|
waveStyle,
|
|
4721
|
+
maxRows: composerEditorCap,
|
|
4722
|
+
onEditorRows: handleEditorRows,
|
|
3896
4723
|
}),
|
|
3897
4724
|
createElement(StatusLine, {
|
|
3898
4725
|
facts: {
|