dsh-code 1.0.2 → 1.0.3
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 +21 -13
- package/README.md +21 -13
- package/lib/index.mjs +1156 -720
- package/lib/types/app.d.ts +2 -0
- package/lib/types/editor-keys.d.ts +105 -0
- package/lib/types/git-workflow.d.ts +6 -2
- package/lib/types/model-capabilities.d.ts +82 -0
- package/lib/types/provider-settings.d.ts +7 -0
- package/lib/types/render/lines.d.ts +25 -0
- package/lib/types/render/markdown.d.ts +1 -1
- package/lib/types/render/projection.d.ts +15 -1
- package/lib/types/render/text.d.ts +15 -9
- package/lib/types/render/width.d.ts +29 -0
- package/lib/types/session-directory.d.ts +27 -0
- package/lib/types/settings-file.d.ts +33 -0
- package/lib/types/store.d.ts +10 -0
- package/lib/types/subagents.d.ts +13 -3
- package/package.json +159 -159
- package/src/app.ts +1104 -1041
- package/src/editor-keys.ts +371 -0
- package/src/git-workflow.ts +10 -6
- package/src/index.ts +1637 -1523
- package/src/model-capabilities.ts +318 -0
- package/src/provider-settings.ts +16 -0
- package/src/render/lines.ts +403 -356
- package/src/render/markdown.ts +4 -7
- package/src/render/projection.ts +63 -40
- package/src/render/text.ts +152 -150
- package/src/render/width.ts +189 -0
- package/src/session-directory.ts +56 -0
- package/src/settings-file.ts +56 -0
- package/src/store.ts +26 -7
- package/src/subagents.ts +39 -6
package/src/app.ts
CHANGED
|
@@ -14,16 +14,16 @@
|
|
|
14
14
|
* @module @deepseek-ai/dsh-code/app
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { basename } from 'node:path'
|
|
18
|
-
import {
|
|
17
|
+
import { basename } from 'node:path'
|
|
18
|
+
import {
|
|
19
19
|
createElement, memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactElement,
|
|
20
20
|
} from 'react'
|
|
21
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'
|
|
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'
|
|
27
27
|
import {
|
|
28
28
|
dim,
|
|
29
29
|
getPalette,
|
|
@@ -40,20 +40,20 @@ import type { TranscriptStore } from './store.ts'
|
|
|
40
40
|
import { settledEntryCount, type TranscriptEntry } from './render/projection.ts'
|
|
41
41
|
import { type MdSegment, visibleColumns } from './render/markdown.ts'
|
|
42
42
|
import {
|
|
43
|
-
busyChaseFrame,
|
|
44
|
-
BUSY_CHASE_TICK_MS,
|
|
45
|
-
caretVisible,
|
|
46
|
-
DEEP_DIVING_SHIMMER_TICK_MS,
|
|
47
|
-
DEEPSEEK_WAVE_TICK_MS,
|
|
48
|
-
deepseekWaveColumnBg,
|
|
49
|
-
deepseekWaveDuration,
|
|
50
|
-
deepseekWaveSpark,
|
|
51
|
-
deepseekWaveStyleRandom,
|
|
52
|
-
deepseekWaveTier,
|
|
53
|
-
deepseekWaveWordHue,
|
|
54
|
-
deepseekWaveWordVisible,
|
|
55
|
-
deepDivingGradientColor,
|
|
56
|
-
deepDivingSparkColor,
|
|
43
|
+
busyChaseFrame,
|
|
44
|
+
BUSY_CHASE_TICK_MS,
|
|
45
|
+
caretVisible,
|
|
46
|
+
DEEP_DIVING_SHIMMER_TICK_MS,
|
|
47
|
+
DEEPSEEK_WAVE_TICK_MS,
|
|
48
|
+
deepseekWaveColumnBg,
|
|
49
|
+
deepseekWaveDuration,
|
|
50
|
+
deepseekWaveSpark,
|
|
51
|
+
deepseekWaveStyleRandom,
|
|
52
|
+
deepseekWaveTier,
|
|
53
|
+
deepseekWaveWordHue,
|
|
54
|
+
deepseekWaveWordVisible,
|
|
55
|
+
deepDivingGradientColor,
|
|
56
|
+
deepDivingSparkColor,
|
|
57
57
|
effortAboveHigh,
|
|
58
58
|
isOfficialDeepSeekLabel,
|
|
59
59
|
type DeepseekWaveStyle,
|
|
@@ -65,7 +65,7 @@ import type { ModelDirectory, ModelRow } from './models.ts'
|
|
|
65
65
|
import type { ProviderConfiguration, ProviderSettingsDirectory, ProviderTargetView } from './provider-settings.ts'
|
|
66
66
|
import type { QuestionSnapshot, QuestionStore } from './questions.ts'
|
|
67
67
|
import type { SkillsView, SkillRow } from './skills.ts'
|
|
68
|
-
import { isPathLikeMentionQuery, type MentionCandidate } from './mentions.ts'
|
|
68
|
+
import { isPathLikeMentionQuery, type MentionCandidate } from './mentions.ts'
|
|
69
69
|
import type { SubagentFeedView, SubagentRow } from './subagents.ts'
|
|
70
70
|
import { AgentsPanel, EffortPanel, HistoryPanel, JobsPanel, ModePanel, PermissionPanel, PluginPanel, ResumePanel, StatuslinePanel, runClock, SubagentPanel, type JobRow } from './kernel-panels.ts'
|
|
71
71
|
import type { PresetRow } from './presets.ts'
|
|
@@ -80,19 +80,19 @@ import {
|
|
|
80
80
|
type RecallState,
|
|
81
81
|
} from './history.ts'
|
|
82
82
|
import type { SessionDirectoryOptions, SessionRow } from './session-directory.ts'
|
|
83
|
-
import type { GitDiffView } from './git-workflow.ts'
|
|
84
|
-
import {
|
|
85
|
-
authorizationForProvider,
|
|
86
|
-
providerAuthorizationStatus,
|
|
87
|
-
type ProviderAuthorizationDirectory,
|
|
88
|
-
type ProviderAuthorizationRow,
|
|
89
|
-
} from './authorization.ts'
|
|
90
|
-
import { ProviderAuthorizationLogoutPanel, ProviderAuthorizationPanel } from './authorization-panel.ts'
|
|
91
|
-
import {
|
|
92
|
-
looksLikeImagePath,
|
|
93
|
-
parsePastedImagePaths,
|
|
94
|
-
type ImagePathInspection,
|
|
95
|
-
} from './attachments.ts'
|
|
83
|
+
import type { GitDiffView } from './git-workflow.ts'
|
|
84
|
+
import {
|
|
85
|
+
authorizationForProvider,
|
|
86
|
+
providerAuthorizationStatus,
|
|
87
|
+
type ProviderAuthorizationDirectory,
|
|
88
|
+
type ProviderAuthorizationRow,
|
|
89
|
+
} from './authorization.ts'
|
|
90
|
+
import { ProviderAuthorizationLogoutPanel, ProviderAuthorizationPanel } from './authorization-panel.ts'
|
|
91
|
+
import {
|
|
92
|
+
looksLikeImagePath,
|
|
93
|
+
parsePastedImagePaths,
|
|
94
|
+
type ImagePathInspection,
|
|
95
|
+
} from './attachments.ts'
|
|
96
96
|
|
|
97
97
|
/** Match Codex's settled-resize window before rebuilding terminal scrollback. */
|
|
98
98
|
const RESIZE_REFLOW_DELAY_MS = 75
|
|
@@ -144,16 +144,16 @@ import {
|
|
|
144
144
|
type StatusTone,
|
|
145
145
|
} from './render/status.ts'
|
|
146
146
|
import { displayTail, displayText, singleLineText, truncateColumns } from './render/text.ts'
|
|
147
|
-
import {
|
|
148
|
-
isVsCodeTerminalEnv,
|
|
149
|
-
normalizeKeyboardChunk,
|
|
150
|
-
PASTE_END_MARKER,
|
|
151
|
-
PASTE_START_MARKER,
|
|
152
|
-
stripPasteMarkers,
|
|
153
|
-
stripTerminalFocusEvents,
|
|
154
|
-
tokenizeRawEditorChunk,
|
|
155
|
-
type RawEditorToken,
|
|
156
|
-
} from './keyboard.ts'
|
|
147
|
+
import {
|
|
148
|
+
isVsCodeTerminalEnv,
|
|
149
|
+
normalizeKeyboardChunk,
|
|
150
|
+
PASTE_END_MARKER,
|
|
151
|
+
PASTE_START_MARKER,
|
|
152
|
+
stripPasteMarkers,
|
|
153
|
+
stripTerminalFocusEvents,
|
|
154
|
+
tokenizeRawEditorChunk,
|
|
155
|
+
type RawEditorToken,
|
|
156
|
+
} from './keyboard.ts'
|
|
157
157
|
import {
|
|
158
158
|
clampScroll,
|
|
159
159
|
followInspectorCursor,
|
|
@@ -165,6 +165,7 @@ import {
|
|
|
165
165
|
selectionWindow,
|
|
166
166
|
} from './render/inspector.ts'
|
|
167
167
|
import {
|
|
168
|
+
clampLiveAllocation,
|
|
168
169
|
lineSegment,
|
|
169
170
|
markdownLines,
|
|
170
171
|
settledEntryLines,
|
|
@@ -181,59 +182,60 @@ import {
|
|
|
181
182
|
deleteBackward,
|
|
182
183
|
deleteForward,
|
|
183
184
|
deleteLastGrapheme,
|
|
184
|
-
deleteWordBackward,
|
|
185
|
-
deleteWordForward,
|
|
186
|
-
editorModel,
|
|
187
|
-
editorRowParts,
|
|
185
|
+
deleteWordBackward,
|
|
186
|
+
deleteWordForward,
|
|
187
|
+
editorModel,
|
|
188
|
+
editorRowParts,
|
|
188
189
|
insertText,
|
|
189
190
|
type EditResult,
|
|
190
191
|
killToLineEnd,
|
|
191
192
|
killToLineStart,
|
|
192
|
-
moveCursorBy,
|
|
193
|
-
moveCursorVertically,
|
|
194
|
-
moveToLineEnd,
|
|
195
|
-
moveToLineStart,
|
|
196
|
-
moveWordLeft,
|
|
197
|
-
moveWordRight,
|
|
198
|
-
remapStableRange,
|
|
199
|
-
replaceRangePreservingCursor,
|
|
193
|
+
moveCursorBy,
|
|
194
|
+
moveCursorVertically,
|
|
195
|
+
moveToLineEnd,
|
|
196
|
+
moveToLineStart,
|
|
197
|
+
moveWordLeft,
|
|
198
|
+
moveWordRight,
|
|
199
|
+
remapStableRange,
|
|
200
|
+
replaceRangePreservingCursor,
|
|
200
201
|
sanitizeDraftText,
|
|
201
202
|
shouldRecallNavigate,
|
|
202
203
|
splitGraphemes,
|
|
203
204
|
} from './render/editor.ts'
|
|
204
205
|
|
|
205
|
-
/** Visual priority for one bounded local notice. */
|
|
206
|
-
export type NoticeTone = 'info' | 'warning' | 'error'
|
|
207
|
-
|
|
208
|
-
/** One source of truth for TUI-owned slash commands in completion and `/help`. */
|
|
209
|
-
const LOCAL_COMMANDS = [
|
|
210
|
-
{ label: '/help', description: 'show this overlay' },
|
|
211
|
-
{ label: '/model', description: 'switch the model and manage providers' },
|
|
212
|
-
{ label: '/effort', description: 'adjust reasoning effort for the current model' },
|
|
213
|
-
{ label: '/mode', description: 'inspect or select the agent preset (/mode [preset])' },
|
|
214
|
-
{ label: '/permission', description: 'inspect or select the permission preset (/permission [preset])' },
|
|
215
|
-
{ label: '/new', description: 'create and switch to a fresh session (/new [preset])' },
|
|
216
|
-
{ label: '/fork', description: 'fork at the latest completed turn (/fork [event-seq])' },
|
|
217
|
-
{ label: '/resume', description: 'browse or switch root sessions (/resume [id|prefix])' },
|
|
218
|
-
{ label: '/plugin', description: 'inspect the live plugin composition' },
|
|
219
|
-
{ label: '/jobs', description: 'inspect background jobs' },
|
|
220
|
-
{ label: '/statusline', description: 'customize the status line items' },
|
|
221
|
-
{ label: '/theme', description: 'switch the color theme' },
|
|
222
|
-
{ label: '/history', description: 'search and recall past prompts' },
|
|
223
|
-
{ label: '/agents', description: 'inspect subagent sessions of this conversation' },
|
|
224
|
-
{ label: '/todos', description: 'inspect the full todo list' },
|
|
225
|
-
{ label: '/subagent', description: 'choose the model delegated subagents run on' },
|
|
226
|
-
{ label: '/
|
|
227
|
-
{ label: '/
|
|
228
|
-
{ label: '/
|
|
229
|
-
{ label: '/
|
|
230
|
-
{ label: '/
|
|
231
|
-
{ label: '/
|
|
232
|
-
{ label: '/
|
|
233
|
-
{ label: '/
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
206
|
+
/** Visual priority for one bounded local notice. */
|
|
207
|
+
export type NoticeTone = 'info' | 'warning' | 'error'
|
|
208
|
+
|
|
209
|
+
/** One source of truth for TUI-owned slash commands in completion and `/help`. */
|
|
210
|
+
const LOCAL_COMMANDS = [
|
|
211
|
+
{ label: '/help', description: 'show this overlay' },
|
|
212
|
+
{ label: '/model', description: 'switch the model and manage providers' },
|
|
213
|
+
{ label: '/effort', description: 'adjust reasoning effort for the current model' },
|
|
214
|
+
{ label: '/mode', description: 'inspect or select the agent preset (/mode [preset])' },
|
|
215
|
+
{ label: '/permission', description: 'inspect or select the permission preset (/permission [preset])' },
|
|
216
|
+
{ label: '/new', description: 'create and switch to a fresh session (/new [preset])' },
|
|
217
|
+
{ label: '/fork', description: 'fork at the latest completed turn (/fork [event-seq])' },
|
|
218
|
+
{ label: '/resume', description: 'browse or switch root sessions (/resume [id|prefix])' },
|
|
219
|
+
{ label: '/plugin', description: 'inspect the live plugin composition' },
|
|
220
|
+
{ label: '/jobs', description: 'inspect background jobs' },
|
|
221
|
+
{ label: '/statusline', description: 'customize the status line items' },
|
|
222
|
+
{ label: '/theme', description: 'switch the color theme' },
|
|
223
|
+
{ label: '/history', description: 'search and recall past prompts' },
|
|
224
|
+
{ label: '/agents', description: 'inspect subagent sessions of this conversation' },
|
|
225
|
+
{ label: '/todos', description: 'inspect the full todo list' },
|
|
226
|
+
{ label: '/subagent', description: 'choose the model delegated subagents run on' },
|
|
227
|
+
{ label: '/vscode-keys', description: 'pass ctrl+r through the vs code terminal' },
|
|
228
|
+
{ label: '/delete', description: 'delete a session and its subagent threads' },
|
|
229
|
+
{ label: '/clear', description: 'clear the screen' },
|
|
230
|
+
{ label: '/export', description: 'export the transcript to markdown (/export [path])' },
|
|
231
|
+
{ label: '/title', description: 'rename this session (/title <text>)' },
|
|
232
|
+
{ label: '/copy', description: 'copy the latest assistant response' },
|
|
233
|
+
{ label: '/diff', description: 'inspect Git changes (/diff [--staged|ref])' },
|
|
234
|
+
{ label: '/review', description: 'review Git changes under read-only permissions' },
|
|
235
|
+
{ label: '/quit', description: 'exit' },
|
|
236
|
+
] as const
|
|
237
|
+
|
|
238
|
+
const LOCAL_COMMAND_NAMES = new Set(LOCAL_COMMANDS.map(command => command.label.slice(1)))
|
|
237
239
|
|
|
238
240
|
/** Props the runner hands the app; callbacks stay owned by the runner. */
|
|
239
241
|
export interface AppProps {
|
|
@@ -268,9 +270,9 @@ export interface AppProps {
|
|
|
268
270
|
/** Permission preset selected for the current or pending first session. */
|
|
269
271
|
permission: string
|
|
270
272
|
/** Submit one line: slash commands to the registry, other text to the agent. */
|
|
271
|
-
dispatch(text: string, images?: readonly ImageBlock[]): void
|
|
272
|
-
/** Submit steering: consumed at the running turn's next step boundary. */
|
|
273
|
-
steer(text: string, images?: readonly ImageBlock[]): void
|
|
273
|
+
dispatch(text: string, images?: readonly ImageBlock[]): void
|
|
274
|
+
/** Submit steering: consumed at the running turn's next step boundary. */
|
|
275
|
+
steer(text: string, images?: readonly ImageBlock[]): void
|
|
274
276
|
/** Interrupt the running turn (Esc); true when a turn was cancelled. */
|
|
275
277
|
interrupt(): boolean
|
|
276
278
|
/** Quit: unmount, flush, and request process exit. */
|
|
@@ -278,11 +280,11 @@ export interface AppProps {
|
|
|
278
280
|
/** Load the selectable model directory (called when /model opens). */
|
|
279
281
|
loadModels(): Promise<ModelDirectory>
|
|
280
282
|
/** Load @mention candidates for the typed query (files + sessions). */
|
|
281
|
-
loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
|
|
282
|
-
/** Validate draft image paths without committing attachment objects. */
|
|
283
|
-
inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
|
|
284
|
-
/** Validate, normalize and persist images immediately before submission. */
|
|
285
|
-
prepareImages(paths: readonly string[], signal?: AbortSignal): Promise<readonly ImageBlock[]>
|
|
283
|
+
loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
|
|
284
|
+
/** Validate draft image paths without committing attachment objects. */
|
|
285
|
+
inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
|
|
286
|
+
/** Validate, normalize and persist images immediately before submission. */
|
|
287
|
+
prepareImages(paths: readonly string[], signal?: AbortSignal): Promise<readonly ImageBlock[]>
|
|
286
288
|
/** Apply one /model selection (with an advertised reasoning effort, when picked); returns the display label. */
|
|
287
289
|
selectModel(row: ModelRow, effortId?: string): string
|
|
288
290
|
/** The /subagent override label, '' when delegated agents follow the current model. */
|
|
@@ -303,21 +305,21 @@ export interface AppProps {
|
|
|
303
305
|
unsetModelProviderCredential?(target: ProviderTargetView): Promise<void>
|
|
304
306
|
/** Remove one user-owned provider profile and its page-managed credential. */
|
|
305
307
|
removeModelProvider?(target: ProviderTargetView): Promise<void>
|
|
306
|
-
/** Save endpoint and explicit model capacities through the provider profile. */
|
|
307
|
-
saveModelProviderConfiguration?(target: ProviderTargetView, configuration: ProviderConfiguration): Promise<void>
|
|
308
|
-
/** Provider authorization flows and value-free stored-record facts. */
|
|
309
|
-
loadProviderAuthorizations?(): Promise<ProviderAuthorizationDirectory>
|
|
310
|
-
subscribeProviderAuthorizations?(listener: () => void): () => void
|
|
311
|
-
beginProviderAuthorization?(
|
|
312
|
-
row: ProviderAuthorizationRow,
|
|
313
|
-
method: string,
|
|
314
|
-
interaction: AuthorizationInteraction,
|
|
315
|
-
signal: AbortSignal,
|
|
316
|
-
): Promise<AuthorizationStatus>
|
|
317
|
-
cancelProviderAuthorization?(row: ProviderAuthorizationRow): void
|
|
318
|
-
logoutProviderAuthorization?(row: ProviderAuthorizationRow): Promise<void>
|
|
319
|
-
openAuthorizationUrl?(url: string): boolean
|
|
320
|
-
copyTextValue?(text: string): Promise<void>
|
|
308
|
+
/** Save endpoint and explicit model capacities through the provider profile. */
|
|
309
|
+
saveModelProviderConfiguration?(target: ProviderTargetView, configuration: ProviderConfiguration): Promise<void>
|
|
310
|
+
/** Provider authorization flows and value-free stored-record facts. */
|
|
311
|
+
loadProviderAuthorizations?(): Promise<ProviderAuthorizationDirectory>
|
|
312
|
+
subscribeProviderAuthorizations?(listener: () => void): () => void
|
|
313
|
+
beginProviderAuthorization?(
|
|
314
|
+
row: ProviderAuthorizationRow,
|
|
315
|
+
method: string,
|
|
316
|
+
interaction: AuthorizationInteraction,
|
|
317
|
+
signal: AbortSignal,
|
|
318
|
+
): Promise<AuthorizationStatus>
|
|
319
|
+
cancelProviderAuthorization?(row: ProviderAuthorizationRow): void
|
|
320
|
+
logoutProviderAuthorization?(row: ProviderAuthorizationRow): Promise<void>
|
|
321
|
+
openAuthorizationUrl?(url: string): boolean
|
|
322
|
+
copyTextValue?(text: string): Promise<void>
|
|
321
323
|
/** Cycle to the next permission preset (Shift+Tab); returns the new label. */
|
|
322
324
|
cyclePermission(): string
|
|
323
325
|
/** Select or inspect a permission preset without requiring a pre-existing session. */
|
|
@@ -363,6 +365,8 @@ export interface AppProps {
|
|
|
363
365
|
recordHistory(text: string): void
|
|
364
366
|
/** Cancel one queued inbox message by identity (Delete on the empty composer). */
|
|
365
367
|
cancelQueued(messageId: string): void
|
|
368
|
+
/** Apply the Ctrl+R terminal passthrough to the detected editor (/vscode-keys); resolves to a one-line summary. */
|
|
369
|
+
applyEditorKeys(): Promise<string>
|
|
366
370
|
}
|
|
367
371
|
|
|
368
372
|
/** Pad text with spaces to a visible-column target (menu name column). */
|
|
@@ -372,15 +376,15 @@ function padColumns(text: string, width: number): string {
|
|
|
372
376
|
}
|
|
373
377
|
|
|
374
378
|
/** Interval-driven frame counter for one self-contained animated leaf. */
|
|
375
|
-
function useFrames(intervalMs: number, active = true): number {
|
|
376
|
-
const [tick, setTick] = useState(0)
|
|
377
|
-
useEffect(() => {
|
|
378
|
-
if (!active) return
|
|
379
|
-
const id = setInterval(() => setTick(current => current + 1), intervalMs)
|
|
379
|
+
function useFrames(intervalMs: number, active = true): number {
|
|
380
|
+
const [tick, setTick] = useState(0)
|
|
381
|
+
useEffect(() => {
|
|
382
|
+
if (!active) return
|
|
383
|
+
const id = setInterval(() => setTick(current => current + 1), intervalMs)
|
|
380
384
|
return () => {
|
|
381
385
|
clearInterval(id)
|
|
382
386
|
}
|
|
383
|
-
}, [active, intervalMs])
|
|
387
|
+
}, [active, intervalMs])
|
|
384
388
|
return tick
|
|
385
389
|
}
|
|
386
390
|
|
|
@@ -399,11 +403,11 @@ function useStableInput(handler: (input: string, key: Key) => void, active: bool
|
|
|
399
403
|
useInput(stableHandler, { isActive: active })
|
|
400
404
|
}
|
|
401
405
|
|
|
402
|
-
/** The original web StateDot chase used by the busy composer marker. */
|
|
403
|
-
function BusyChase(): ReactElement {
|
|
404
|
-
const tick = useFrames(BUSY_CHASE_TICK_MS)
|
|
405
|
-
return createElement(Text, { color: inkColor(getPalette().brandBright) }, busyChaseFrame(tick) + ' ')
|
|
406
|
-
}
|
|
406
|
+
/** The original web StateDot chase used by the busy composer marker. */
|
|
407
|
+
function BusyChase(): ReactElement {
|
|
408
|
+
const tick = useFrames(BUSY_CHASE_TICK_MS)
|
|
409
|
+
return createElement(Text, { color: inkColor(getPalette().brandBright) }, busyChaseFrame(tick) + ' ')
|
|
410
|
+
}
|
|
407
411
|
|
|
408
412
|
/** Blinking block caret appended to streaming text. */
|
|
409
413
|
function Caret(): ReactElement {
|
|
@@ -411,54 +415,64 @@ function Caret(): ReactElement {
|
|
|
411
415
|
return createElement(Text, null, caretVisible(tick) ? '▍' : ' ')
|
|
412
416
|
}
|
|
413
417
|
|
|
414
|
-
/** One resettable input-caret phase shared by the entire composer. */
|
|
415
|
-
function useCursorBlink(active: boolean): { visible: boolean; reset(): void } {
|
|
416
|
-
const [epoch, setEpoch] = useState(0)
|
|
417
|
-
const [visible, setVisible] = useState(true)
|
|
418
|
-
useEffect(() => {
|
|
419
|
-
setVisible(true)
|
|
420
|
-
if (!active) return
|
|
421
|
-
const id = setInterval(() => setVisible(current => !current), 530)
|
|
422
|
-
return () => {
|
|
423
|
-
clearInterval(id)
|
|
424
|
-
}
|
|
425
|
-
}, [active, epoch])
|
|
426
|
-
const reset = useCallback((): void => {
|
|
427
|
-
setVisible(true)
|
|
428
|
-
setEpoch(current => current + 1)
|
|
429
|
-
}, [])
|
|
430
|
-
return { visible, reset }
|
|
431
|
-
}
|
|
418
|
+
/** One resettable input-caret phase shared by the entire composer. */
|
|
419
|
+
function useCursorBlink(active: boolean): { visible: boolean; reset(): void } {
|
|
420
|
+
const [epoch, setEpoch] = useState(0)
|
|
421
|
+
const [visible, setVisible] = useState(true)
|
|
422
|
+
useEffect(() => {
|
|
423
|
+
setVisible(true)
|
|
424
|
+
if (!active) return
|
|
425
|
+
const id = setInterval(() => setVisible(current => !current), 530)
|
|
426
|
+
return () => {
|
|
427
|
+
clearInterval(id)
|
|
428
|
+
}
|
|
429
|
+
}, [active, epoch])
|
|
430
|
+
const reset = useCallback((): void => {
|
|
431
|
+
setVisible(true)
|
|
432
|
+
setEpoch(current => current + 1)
|
|
433
|
+
}, [])
|
|
434
|
+
return { visible, reset }
|
|
435
|
+
}
|
|
432
436
|
|
|
433
437
|
/**
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*/
|
|
439
|
-
function
|
|
440
|
-
const tick = useFrames(DEEP_DIVING_SHIMMER_TICK_MS)
|
|
441
|
-
const
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
438
|
+
* One bounded line painted with the deep-diving shimmer: a continuously
|
|
439
|
+
* moving blue gradient across graphemes, the `✻` glyph in the breathing
|
|
440
|
+
* spark color. Shared by the busy line and the collapsed thinking marker;
|
|
441
|
+
* always exactly one row (truncate-end) so the live budget stays exact.
|
|
442
|
+
*/
|
|
443
|
+
function ShimmerLine({ text }: { text: string }): ReactElement {
|
|
444
|
+
const tick = useFrames(DEEP_DIVING_SHIMMER_TICK_MS)
|
|
445
|
+
const palette = getPalette()
|
|
446
|
+
const graphemes = splitGraphemes(text)
|
|
447
|
+
return createElement(
|
|
448
|
+
Text,
|
|
449
|
+
{ wrap: 'truncate-end' },
|
|
450
|
+
...graphemes.map((grapheme, index) => {
|
|
451
|
+
const sparkle = grapheme.text === '✻'
|
|
452
|
+
return createElement(
|
|
453
|
+
Text,
|
|
454
|
+
{
|
|
455
|
+
key: `${grapheme.start}-${grapheme.end}`,
|
|
456
|
+
color: inkColor(sparkle ? deepDivingSparkColor(tick, palette.brandDeep, palette.brandBright) : deepDivingGradientColor(index, tick, graphemes.length, palette.brandDeep, palette.brandBright)),
|
|
457
|
+
bold: sparkle || undefined,
|
|
458
|
+
},
|
|
459
|
+
grapheme.text,
|
|
460
|
+
)
|
|
461
|
+
}),
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* The busy line, web TurnStatus contract: a continuously moving blue gradient
|
|
467
|
+
* paints the complete `Deep diving...` label, with the elapsed clock appended
|
|
468
|
+
* only once the turn has clearly been running (15s) — anchored to `turn/start`
|
|
469
|
+
* so a resumed mid-turn keeps the real time.
|
|
470
|
+
*/
|
|
471
|
+
function DeepDivingLine({ since }: { since: number }): ReactElement {
|
|
472
|
+
const elapsed = since === 0 ? 0 : Date.now() - since
|
|
473
|
+
const text = elapsed >= 15_000 ? `✻ Deep diving... ${runClock(elapsed)}` : '✻ Deep diving...'
|
|
474
|
+
return createElement(ShimmerLine, { text })
|
|
475
|
+
}
|
|
462
476
|
|
|
463
477
|
/**
|
|
464
478
|
* The streaming buffer rendered with a hard size cap: the live region must
|
|
@@ -699,11 +713,12 @@ function todoMark(status: TodoItem['status']): string {
|
|
|
699
713
|
|
|
700
714
|
/**
|
|
701
715
|
* One-row live subagent summary (the Codex agent status feed, compressed to
|
|
702
|
-
* the transcript's budget): running count, total
|
|
703
|
-
*
|
|
704
|
-
* the
|
|
716
|
+
* the transcript's budget): running count, the observed total (the row cap
|
|
717
|
+
* is a display budget, not the fan-out size), and the most recently active
|
|
718
|
+
* child's current activity. One line, never more — the full view is the
|
|
719
|
+
* /agents panel.
|
|
705
720
|
*/
|
|
706
|
-
function AgentsLine({ rows }: { rows: readonly SubagentRow[] }): ReactElement | undefined {
|
|
721
|
+
function AgentsLine({ rows, total }: { rows: readonly SubagentRow[]; total: number }): ReactElement | undefined {
|
|
707
722
|
if (rows.length === 0) return undefined
|
|
708
723
|
const running = rows.filter(row => row.state !== 'done').length
|
|
709
724
|
const newest = [...rows].sort((left, right) => right.updatedAt - left.updatedAt)[0]!
|
|
@@ -715,7 +730,7 @@ function AgentsLine({ rows }: { rows: readonly SubagentRow[] }): ReactElement |
|
|
|
715
730
|
Text,
|
|
716
731
|
{ color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' },
|
|
717
732
|
`agents ${running} live`,
|
|
718
|
-
createElement(Text, { color: inkColor(getPalette().dim) }, ` · ${
|
|
733
|
+
createElement(Text, { color: inkColor(getPalette().dim) }, ` · ${total} total · /agents`),
|
|
719
734
|
createElement(Text, { color: inkColor(getPalette().text) }, ` · ${mark} ${newest.label} ${newest.activity}`),
|
|
720
735
|
),
|
|
721
736
|
)
|
|
@@ -1523,10 +1538,10 @@ function ModelPanel({ directory, error, current, onSelect, onProviders, onRetry,
|
|
|
1523
1538
|
createElement(Text, { color: inkColor(getPalette().brand), bold: true, wrap: 'truncate-end' }, truncateColumns(`/model — select model${rows.length === 0 ? '' : ` · ${cursor + 1}/${rows.length}`}`, viewport.contentColumns)),
|
|
1524
1539
|
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1525
1540
|
...visibleStateRows,
|
|
1526
|
-
...visible.map((row) => {
|
|
1527
|
-
const index = rows.indexOf(row)
|
|
1528
|
-
const capability = row.inputModalities?.includes('image') === true ? ' · image' : ''
|
|
1529
|
-
const label = displayText(`${row.providerName} · ${row.modelName}${capability}`)
|
|
1541
|
+
...visible.map((row) => {
|
|
1542
|
+
const index = rows.indexOf(row)
|
|
1543
|
+
const capability = row.inputModalities?.includes('image') === true ? ' · image' : ''
|
|
1544
|
+
const label = displayText(`${row.providerName} · ${row.modelName}${capability}`)
|
|
1530
1545
|
return createElement(
|
|
1531
1546
|
Text,
|
|
1532
1547
|
{
|
|
@@ -1556,17 +1571,17 @@ function providerStateLabel(row: ProviderTargetView): string {
|
|
|
1556
1571
|
}
|
|
1557
1572
|
|
|
1558
1573
|
/** The provider-management stage reached from /model with `a`. */
|
|
1559
|
-
function ProviderPanel({ directory, error, authorizations, authorizationError, onCredential, onConfigure, onUnset, onRemove, onLogin, onLogout, onRetry, onBack }: {
|
|
1560
|
-
directory: ProviderSettingsDirectory | undefined
|
|
1561
|
-
error: string | undefined
|
|
1562
|
-
authorizations: ProviderAuthorizationDirectory | undefined
|
|
1563
|
-
authorizationError: string | undefined
|
|
1564
|
-
onCredential(target: ProviderTargetView): void
|
|
1565
|
-
onConfigure(target: ProviderTargetView): void
|
|
1566
|
-
onUnset(target: ProviderTargetView): void
|
|
1567
|
-
onRemove(target: ProviderTargetView): void
|
|
1568
|
-
onLogin(target: ProviderTargetView, authorization: ProviderAuthorizationRow): void
|
|
1569
|
-
onLogout(target: ProviderTargetView, authorization: ProviderAuthorizationRow): void
|
|
1574
|
+
function ProviderPanel({ directory, error, authorizations, authorizationError, onCredential, onConfigure, onUnset, onRemove, onLogin, onLogout, onRetry, onBack }: {
|
|
1575
|
+
directory: ProviderSettingsDirectory | undefined
|
|
1576
|
+
error: string | undefined
|
|
1577
|
+
authorizations: ProviderAuthorizationDirectory | undefined
|
|
1578
|
+
authorizationError: string | undefined
|
|
1579
|
+
onCredential(target: ProviderTargetView): void
|
|
1580
|
+
onConfigure(target: ProviderTargetView): void
|
|
1581
|
+
onUnset(target: ProviderTargetView): void
|
|
1582
|
+
onRemove(target: ProviderTargetView): void
|
|
1583
|
+
onLogin(target: ProviderTargetView, authorization: ProviderAuthorizationRow): void
|
|
1584
|
+
onLogout(target: ProviderTargetView, authorization: ProviderAuthorizationRow): void
|
|
1570
1585
|
onRetry(): void
|
|
1571
1586
|
onBack(): void
|
|
1572
1587
|
}): ReactElement {
|
|
@@ -1633,27 +1648,27 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
|
|
|
1633
1648
|
}
|
|
1634
1649
|
return
|
|
1635
1650
|
}
|
|
1636
|
-
if (input === 'x') {
|
|
1651
|
+
if (input === 'x') {
|
|
1637
1652
|
if (!target.removable) {
|
|
1638
1653
|
setActionError('this provider profile is not removable')
|
|
1639
1654
|
} else {
|
|
1640
1655
|
onRemove(target)
|
|
1641
1656
|
}
|
|
1642
|
-
return
|
|
1643
|
-
}
|
|
1644
|
-
const authorization = authorizationForProvider(authorizations, target.provider)
|
|
1645
|
-
if (input === 'l' || input === 'L') {
|
|
1646
|
-
if (authorization === undefined) setActionError('this provider offers no interactive login flow')
|
|
1647
|
-
else if (authorization.inFlight) setActionError('a login attempt is already running for this provider')
|
|
1648
|
-
else onLogin(target, authorization)
|
|
1649
|
-
return
|
|
1650
|
-
}
|
|
1651
|
-
if (input === 'o' || input === 'O') {
|
|
1652
|
-
if (authorization === undefined || !authorization.record.configured) setActionError('this provider has no login record to remove')
|
|
1653
|
-
else if (!authorization.record.writable) setActionError('this login record is read-only')
|
|
1654
|
-
else onLogout(target, authorization)
|
|
1655
|
-
return
|
|
1656
|
-
}
|
|
1657
|
+
return
|
|
1658
|
+
}
|
|
1659
|
+
const authorization = authorizationForProvider(authorizations, target.provider)
|
|
1660
|
+
if (input === 'l' || input === 'L') {
|
|
1661
|
+
if (authorization === undefined) setActionError('this provider offers no interactive login flow')
|
|
1662
|
+
else if (authorization.inFlight) setActionError('a login attempt is already running for this provider')
|
|
1663
|
+
else onLogin(target, authorization)
|
|
1664
|
+
return
|
|
1665
|
+
}
|
|
1666
|
+
if (input === 'o' || input === 'O') {
|
|
1667
|
+
if (authorization === undefined || !authorization.record.configured) setActionError('this provider has no login record to remove')
|
|
1668
|
+
else if (!authorization.record.writable) setActionError('this login record is read-only')
|
|
1669
|
+
else onLogout(target, authorization)
|
|
1670
|
+
return
|
|
1671
|
+
}
|
|
1657
1672
|
if (key.return) {
|
|
1658
1673
|
if (target.settingsNs.length === 0) {
|
|
1659
1674
|
setActionError('this provider is not managed by Harness settings')
|
|
@@ -1681,19 +1696,19 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
|
|
|
1681
1696
|
...(actionError === undefined
|
|
1682
1697
|
? []
|
|
1683
1698
|
: [createElement(Text, { key: 'action-error', color: inkColor(getPalette().error), wrap: 'truncate-end' }, truncateColumns(` ${actionError}`, viewport.contentColumns))]),
|
|
1684
|
-
...(directory?.failures ?? []).map((failure, index) => createElement(
|
|
1699
|
+
...(directory?.failures ?? []).map((failure, index) => createElement(
|
|
1685
1700
|
Text,
|
|
1686
1701
|
{ key: `failure-${index}`, color: inkColor(getPalette().warn), wrap: 'truncate-end' },
|
|
1687
|
-
truncateColumns(` ${singleLineText(failure)}`, viewport.contentColumns),
|
|
1688
|
-
)),
|
|
1689
|
-
...(authorizationError === undefined
|
|
1690
|
-
? []
|
|
1691
|
-
: [createElement(Text, { key: 'authorization-error', color: inkColor(getPalette().warn), wrap: 'truncate-end' }, truncateColumns(` login status unavailable: ${singleLineText(authorizationError)}`, viewport.contentColumns))]),
|
|
1692
|
-
...(authorizations?.failures ?? []).map((failure, index) => createElement(
|
|
1693
|
-
Text,
|
|
1694
|
-
{ key: `authorization-failure-${index}`, color: inkColor(getPalette().warn), wrap: 'truncate-end' },
|
|
1695
|
-
truncateColumns(` ${singleLineText(failure)}`, viewport.contentColumns),
|
|
1696
|
-
)),
|
|
1702
|
+
truncateColumns(` ${singleLineText(failure)}`, viewport.contentColumns),
|
|
1703
|
+
)),
|
|
1704
|
+
...(authorizationError === undefined
|
|
1705
|
+
? []
|
|
1706
|
+
: [createElement(Text, { key: 'authorization-error', color: inkColor(getPalette().warn), wrap: 'truncate-end' }, truncateColumns(` login status unavailable: ${singleLineText(authorizationError)}`, viewport.contentColumns))]),
|
|
1707
|
+
...(authorizations?.failures ?? []).map((failure, index) => createElement(
|
|
1708
|
+
Text,
|
|
1709
|
+
{ key: `authorization-failure-${index}`, color: inkColor(getPalette().warn), wrap: 'truncate-end' },
|
|
1710
|
+
truncateColumns(` ${singleLineText(failure)}`, viewport.contentColumns),
|
|
1711
|
+
)),
|
|
1697
1712
|
...(rows.length === 0
|
|
1698
1713
|
? [createElement(Text, { key: 'empty', color: inkColor(getPalette().dim), wrap: 'truncate-end' }, ' no configurable providers')]
|
|
1699
1714
|
: []),
|
|
@@ -1709,13 +1724,13 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
|
|
|
1709
1724
|
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1710
1725
|
...visibleStateRows,
|
|
1711
1726
|
...visible.map((row) => {
|
|
1712
|
-
const index = rows.indexOf(row)
|
|
1713
|
-
const identity = row.displayName === row.provider ? row.provider : `${row.displayName} (${row.provider})`
|
|
1714
|
-
const authorization = authorizationForProvider(authorizations, row.provider)
|
|
1715
|
-
const manualKeyConfigured = row.credential?.kind === 'facts' && row.credential.configured
|
|
1716
|
-
const showAuthorization = !manualKeyConfigured || authorization?.record.configured === true || authorization?.inFlight === true
|
|
1717
|
-
const authLabel = showAuthorization ? ` · ${providerAuthorizationStatus(authorization)}` : ''
|
|
1718
|
-
const label = `${identity} · ${providerStateLabel(row)}${authLabel}${row.removable ? ' · custom' : ''}`
|
|
1727
|
+
const index = rows.indexOf(row)
|
|
1728
|
+
const identity = row.displayName === row.provider ? row.provider : `${row.displayName} (${row.provider})`
|
|
1729
|
+
const authorization = authorizationForProvider(authorizations, row.provider)
|
|
1730
|
+
const manualKeyConfigured = row.credential?.kind === 'facts' && row.credential.configured
|
|
1731
|
+
const showAuthorization = !manualKeyConfigured || authorization?.record.configured === true || authorization?.inFlight === true
|
|
1732
|
+
const authLabel = showAuthorization ? ` · ${providerAuthorizationStatus(authorization)}` : ''
|
|
1733
|
+
const label = `${identity} · ${providerStateLabel(row)}${authLabel}${row.removable ? ' · custom' : ''}`
|
|
1719
1734
|
return createElement(
|
|
1720
1735
|
Text,
|
|
1721
1736
|
{ key: row.provider, color: index === cursor ? inkColor(getPalette().brandBright) : inkColor(getPalette().dim), wrap: 'truncate-end' },
|
|
@@ -1723,7 +1738,7 @@ function ProviderPanel({ directory, error, authorizations, authorizationError, o
|
|
|
1723
1738
|
)
|
|
1724
1739
|
}),
|
|
1725
1740
|
createElement(PanelGap, { visible: viewport.gapRows > 0 }),
|
|
1726
|
-
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)),
|
|
1741
|
+
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)),
|
|
1727
1742
|
)
|
|
1728
1743
|
}
|
|
1729
1744
|
|
|
@@ -2004,7 +2019,7 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
|
|
|
2004
2019
|
createElement(Text, { key: 'keys-title', bold: true, wrap: 'truncate-end' }, ' keys'),
|
|
2005
2020
|
createElement(Text, { key: 'key-submit', dimColor: true, wrap: 'truncate-end' }, ' enter submit · up/down history · tab complete'),
|
|
2006
2021
|
createElement(Text, { key: 'key-mentions', dimColor: true, wrap: 'truncate-end' }, ' @ mentions workspace files and sessions'),
|
|
2007
|
-
createElement(Text, { key: 'key-inspector', dimColor: true, wrap: 'truncate-end' }, ' ctrl+o history details · ctrl+r thinking · shift+tab permission preset'),
|
|
2022
|
+
createElement(Text, { key: 'key-inspector', dimColor: true, wrap: 'truncate-end' }, ' ctrl+o history details · ctrl/alt+r thinking · shift+tab permission preset'),
|
|
2008
2023
|
createElement(Text, { key: 'key-cancel', dimColor: true, wrap: 'truncate-end' }, ' esc interrupt the running turn · ctrl+c cancel / clear / quit · ctrl+d exit'),
|
|
2009
2024
|
createElement(Text, { key: 'key-queue', dimColor: true, wrap: 'truncate-end' }, ' delete on the empty composer cancels the newest queued message'),
|
|
2010
2025
|
createElement(Text, { key: 'key-edit', dimColor: true, wrap: 'truncate-end' }, ' ctrl+k cut to end of line · ctrl+u clear line · ctrl+a / ctrl+e line ends'),
|
|
@@ -2017,12 +2032,12 @@ function HelpPanel({ descriptors, skills, commandError, skillError, onClose }: {
|
|
|
2017
2032
|
{ key: 'commands-error', color: inkColor(getPalette().error), wrap: 'truncate-end' },
|
|
2018
2033
|
truncateColumns(` command catalog unavailable: ${singleLineText(commandError)}`, viewport.contentColumns),
|
|
2019
2034
|
)]),
|
|
2020
|
-
...LOCAL_COMMANDS.map(command => createElement(
|
|
2021
|
-
Box,
|
|
2022
|
-
{ key: `local-${command.label.slice(1)}` },
|
|
2023
|
-
row(command.label, command.description),
|
|
2024
|
-
)),
|
|
2025
|
-
...descriptors.filter(descriptor => !LOCAL_COMMAND_NAMES.has(descriptor.name)).map(descriptor => createElement(
|
|
2035
|
+
...LOCAL_COMMANDS.map(command => createElement(
|
|
2036
|
+
Box,
|
|
2037
|
+
{ key: `local-${command.label.slice(1)}` },
|
|
2038
|
+
row(command.label, command.description),
|
|
2039
|
+
)),
|
|
2040
|
+
...descriptors.filter(descriptor => !LOCAL_COMMAND_NAMES.has(descriptor.name)).map(descriptor => createElement(
|
|
2026
2041
|
Text,
|
|
2027
2042
|
{ key: `command-${descriptor.name}`, dimColor: true, wrap: 'truncate-end' },
|
|
2028
2043
|
` ${padColumns(`/${descriptor.name}`, nameWidth)}${dim(truncateColumns(displayText(descriptor.description), descBudget))}`,
|
|
@@ -2089,13 +2104,13 @@ function verboseLine(text: string, columns: number): string {
|
|
|
2089
2104
|
return truncateColumns(displayText(text).replace(/\n/gu, ' ↵ ').replace(/\t/gu, ' '), Math.max(1, columns))
|
|
2090
2105
|
}
|
|
2091
2106
|
|
|
2092
|
-
/** The empty-composer placeholder text (shared by the static and wave paths). */
|
|
2093
|
-
const COMPOSER_PLACEHOLDER = 'type a message · / commands · @ mentions'
|
|
2107
|
+
/** The empty-composer placeholder text (shared by the static and wave paths). */
|
|
2108
|
+
const COMPOSER_PLACEHOLDER = 'type a message · / commands · @ mentions'
|
|
2094
2109
|
|
|
2095
2110
|
/** One physical cell of the wave-painted composer row: a char plus styles. */
|
|
2096
|
-
interface ComposerCell {
|
|
2097
|
-
char: string
|
|
2098
|
-
width?: number
|
|
2111
|
+
interface ComposerCell {
|
|
2112
|
+
char: string
|
|
2113
|
+
width?: number
|
|
2099
2114
|
color?: string
|
|
2100
2115
|
backgroundColor?: string
|
|
2101
2116
|
bold?: boolean
|
|
@@ -2325,12 +2340,12 @@ export function completionCandidates(
|
|
|
2325
2340
|
): readonly CompletionCandidate[] {
|
|
2326
2341
|
if (!value.startsWith('/')) return []
|
|
2327
2342
|
const prefix = value.slice(1).split(' ')[0] ?? ''
|
|
2328
|
-
const local: CompletionCandidate[] = LOCAL_COMMANDS.map(command => ({ ...command, origin: 'command' }))
|
|
2343
|
+
const local: CompletionCandidate[] = LOCAL_COMMANDS.map(command => ({ ...command, origin: 'command' }))
|
|
2329
2344
|
// Local commands shadow registry names (e.g. the TUI-local /permission works
|
|
2330
2345
|
// before any session exists, while the registry child needs one), so
|
|
2331
2346
|
// collisions cannot render two rows with the same key.
|
|
2332
|
-
const registry = descriptors
|
|
2333
|
-
.filter(descriptor => !LOCAL_COMMAND_NAMES.has(descriptor.name))
|
|
2347
|
+
const registry = descriptors
|
|
2348
|
+
.filter(descriptor => !LOCAL_COMMAND_NAMES.has(descriptor.name))
|
|
2334
2349
|
.map((descriptor): CompletionCandidate => ({
|
|
2335
2350
|
label: `/${descriptor.name}`,
|
|
2336
2351
|
description: descriptor.description,
|
|
@@ -2420,25 +2435,25 @@ function CompletionMenu({ active, mention, index, rows }: {
|
|
|
2420
2435
|
)
|
|
2421
2436
|
}
|
|
2422
2437
|
|
|
2423
|
-
interface DraftImage extends ImagePathInspection {
|
|
2424
|
-
/** Visible draft token; deleting it also detaches the hidden path. */
|
|
2425
|
-
readonly marker: string
|
|
2426
|
-
}
|
|
2427
|
-
|
|
2428
|
-
/**
|
|
2429
|
-
* The prompt box: TUI-local slash commands handled locally, other lines
|
|
2438
|
+
interface DraftImage extends ImagePathInspection {
|
|
2439
|
+
/** Visible draft token; deleting it also detaches the hidden path. */
|
|
2440
|
+
readonly marker: string
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
/**
|
|
2444
|
+
* The prompt box: TUI-local slash commands handled locally, other lines
|
|
2430
2445
|
* dispatched; input editing keeps a cursor with history and completion.
|
|
2431
2446
|
* While a modal (approval / question / model panel) owns the keys, the
|
|
2432
2447
|
* box passes every key through untouched.
|
|
2433
2448
|
*/
|
|
2434
|
-
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 }: {
|
|
2449
|
+
function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, interrupt, quit, openModel, openEffort, openHelp, openMode, openPermission, openResume, openPlugin, openJobs, openStatusline, openTheme, openHistory, openAgents, openSubagent, openTodos, openDelete, openDiff, reviewChanges, deleteConfirm, confirmDelete, cancelDelete, createSession, forkSession, cancelSessionSwitch, notify, applyEditorKeys, hasNotice, dismissNotice, toggleReasoning, openVerbose, clearView, refresh, loadMentions, inspectImages, prepareImages, cyclePermission, exportTranscript, renameTitle, copyLastResponse, recallSpace, recordLocal, recordHistory, queued, cancelQueued, historyFill, historyConsumed, waveTier, waveStyle, maxRows, onEditorRows }: {
|
|
2435
2450
|
active: boolean
|
|
2436
2451
|
frozen: boolean
|
|
2437
2452
|
busy: boolean
|
|
2438
2453
|
descriptors: readonly CommandDescriptor[]
|
|
2439
2454
|
skills: readonly SkillRow[]
|
|
2440
|
-
dispatch(text: string, images?: readonly ImageBlock[]): void
|
|
2441
|
-
steer(text: string, images?: readonly ImageBlock[]): void
|
|
2455
|
+
dispatch(text: string, images?: readonly ImageBlock[]): void
|
|
2456
|
+
steer(text: string, images?: readonly ImageBlock[]): void
|
|
2442
2457
|
interrupt(): boolean
|
|
2443
2458
|
quit(): void
|
|
2444
2459
|
openModel(): void
|
|
@@ -2472,15 +2487,17 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2472
2487
|
forkSession(argument: string): void
|
|
2473
2488
|
cancelSessionSwitch(): boolean
|
|
2474
2489
|
notify(text: string, tone?: NoticeTone): void
|
|
2490
|
+
/** Apply the Ctrl+R passthrough to the detected editor (/vscode-keys); resolves to a one-line summary. */
|
|
2491
|
+
applyEditorKeys(): Promise<string>
|
|
2475
2492
|
hasNotice: boolean
|
|
2476
2493
|
dismissNotice(): void
|
|
2477
2494
|
toggleReasoning(): void
|
|
2478
2495
|
openVerbose(): void
|
|
2479
2496
|
clearView(): void
|
|
2480
2497
|
refresh(): void
|
|
2481
|
-
loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
|
|
2482
|
-
inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
|
|
2483
|
-
prepareImages(paths: readonly string[], signal?: AbortSignal): Promise<readonly ImageBlock[]>
|
|
2498
|
+
loadMentions(query: string, signal?: AbortSignal): Promise<readonly MentionCandidate[]>
|
|
2499
|
+
inspectImages(paths: readonly string[]): Promise<readonly ImagePathInspection[]>
|
|
2500
|
+
prepareImages(paths: readonly string[], signal?: AbortSignal): Promise<readonly ImageBlock[]>
|
|
2484
2501
|
cyclePermission(): string
|
|
2485
2502
|
exportTranscript(argument: string): Promise<void>
|
|
2486
2503
|
renameTitle(argument: string): string
|
|
@@ -2511,28 +2528,28 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2511
2528
|
maxRows: number
|
|
2512
2529
|
/** Reports the editor's current physical row count so the live budget stays exact. */
|
|
2513
2530
|
onEditorRows(rows: number): void
|
|
2514
|
-
}): ReactElement {
|
|
2515
|
-
const columns = useStdout().stdout?.columns ?? 80
|
|
2516
|
-
const editorColumns = Math.max(1, columns - 6)
|
|
2517
|
-
const stdin = useStdin().stdin
|
|
2518
|
-
const focusReporting = isVsCodeTerminalEnv()
|
|
2519
|
-
const [value, setValue] = useState('')
|
|
2520
|
-
const [cursor, setCursor] = useState(0)
|
|
2521
|
-
const valueRef = useRef(value)
|
|
2522
|
-
const cursorRef = useRef(cursor)
|
|
2523
|
-
valueRef.current = value
|
|
2524
|
-
cursorRef.current = cursor
|
|
2525
|
-
const [draftImages, setDraftImages] = useState<readonly DraftImage[]>([])
|
|
2526
|
-
const draftImagesRef = useRef(draftImages)
|
|
2527
|
-
draftImagesRef.current = draftImages
|
|
2528
|
-
const [preparingImages, setPreparingImages] = useState(false)
|
|
2529
|
-
const prepareAbortRef = useRef<AbortController | undefined>(undefined)
|
|
2530
|
-
const prepareEpochRef = useRef(0)
|
|
2531
|
-
const { visible: cursorVisible, reset: resetCursorBlink } = useCursorBlink(active && !frozen && !preparingImages)
|
|
2532
|
-
useEffect(() => () => {
|
|
2533
|
-
prepareEpochRef.current += 1
|
|
2534
|
-
prepareAbortRef.current?.abort()
|
|
2535
|
-
}, [])
|
|
2531
|
+
}): ReactElement {
|
|
2532
|
+
const columns = useStdout().stdout?.columns ?? 80
|
|
2533
|
+
const editorColumns = Math.max(1, columns - 6)
|
|
2534
|
+
const stdin = useStdin().stdin
|
|
2535
|
+
const focusReporting = isVsCodeTerminalEnv()
|
|
2536
|
+
const [value, setValue] = useState('')
|
|
2537
|
+
const [cursor, setCursor] = useState(0)
|
|
2538
|
+
const valueRef = useRef(value)
|
|
2539
|
+
const cursorRef = useRef(cursor)
|
|
2540
|
+
valueRef.current = value
|
|
2541
|
+
cursorRef.current = cursor
|
|
2542
|
+
const [draftImages, setDraftImages] = useState<readonly DraftImage[]>([])
|
|
2543
|
+
const draftImagesRef = useRef(draftImages)
|
|
2544
|
+
draftImagesRef.current = draftImages
|
|
2545
|
+
const [preparingImages, setPreparingImages] = useState(false)
|
|
2546
|
+
const prepareAbortRef = useRef<AbortController | undefined>(undefined)
|
|
2547
|
+
const prepareEpochRef = useRef(0)
|
|
2548
|
+
const { visible: cursorVisible, reset: resetCursorBlink } = useCursorBlink(active && !frozen && !preparingImages)
|
|
2549
|
+
useEffect(() => () => {
|
|
2550
|
+
prepareEpochRef.current += 1
|
|
2551
|
+
prepareAbortRef.current?.abort()
|
|
2552
|
+
}, [])
|
|
2536
2553
|
// Codex textarea editing state: a single-entry kill buffer, the vertical
|
|
2537
2554
|
// move's preferred display column, the editor's scroll window, and the
|
|
2538
2555
|
// bracketed-paste marker state. All of it is editor-local; nothing here
|
|
@@ -2543,30 +2560,30 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2543
2560
|
const pasteBracketRef = useRef(false)
|
|
2544
2561
|
/** Cancels the pending lost-paste safety timer (undefined when disarmed). */
|
|
2545
2562
|
const pasteBracketCancelRef = useRef<(() => void) | undefined>(undefined)
|
|
2546
|
-
/** Ordered editor tokens from the stdin chunk Ink is about to deliver. */
|
|
2547
|
-
const rawEditorTokens = useRef<readonly RawEditorToken[] | undefined>(undefined)
|
|
2548
|
-
/** VS Code focus state from xterm focus-report events; starts focused. */
|
|
2549
|
-
const terminalFocusedRef = useRef(true)
|
|
2563
|
+
/** Ordered editor tokens from the stdin chunk Ink is about to deliver. */
|
|
2564
|
+
const rawEditorTokens = useRef<readonly RawEditorToken[] | undefined>(undefined)
|
|
2565
|
+
/** VS Code focus state from xterm focus-report events; starts focused. */
|
|
2566
|
+
const terminalFocusedRef = useRef(true)
|
|
2550
2567
|
// Codex shell-style recall: the navigation cursor, the saved draft restored
|
|
2551
2568
|
// on Down past the newest entry, and the boundary-gate anchor.
|
|
2552
|
-
const recall = useRef<RecallState>(beginRecall([], ''))
|
|
2553
|
-
|
|
2554
|
-
useEffect(() => {
|
|
2555
|
-
preferredColumnRef.current = null
|
|
2556
|
-
}, [editorColumns])
|
|
2569
|
+
const recall = useRef<RecallState>(beginRecall([], ''))
|
|
2570
|
+
|
|
2571
|
+
useEffect(() => {
|
|
2572
|
+
preferredColumnRef.current = null
|
|
2573
|
+
}, [editorColumns])
|
|
2557
2574
|
|
|
2558
2575
|
// A /history panel acceptance lands as a fill: place the sanitized text at
|
|
2559
2576
|
// the end of the composer and resume recall from that entry.
|
|
2560
|
-
useEffect(() => {
|
|
2561
|
-
if (historyFill === undefined) return
|
|
2562
|
-
const safe = sanitizeDraftText(historyFill.text)
|
|
2563
|
-
draftImagesRef.current = []
|
|
2564
|
-
setDraftImages([])
|
|
2565
|
-
valueRef.current = safe
|
|
2566
|
-
cursorRef.current = safe.length
|
|
2567
|
-
setValue(safe)
|
|
2568
|
-
setCursor(safe.length)
|
|
2569
|
-
resetCursorBlink()
|
|
2577
|
+
useEffect(() => {
|
|
2578
|
+
if (historyFill === undefined) return
|
|
2579
|
+
const safe = sanitizeDraftText(historyFill.text)
|
|
2580
|
+
draftImagesRef.current = []
|
|
2581
|
+
setDraftImages([])
|
|
2582
|
+
valueRef.current = safe
|
|
2583
|
+
cursorRef.current = safe.length
|
|
2584
|
+
setValue(safe)
|
|
2585
|
+
setCursor(safe.length)
|
|
2586
|
+
resetCursorBlink()
|
|
2570
2587
|
preferredColumnRef.current = null
|
|
2571
2588
|
setDismissedMenuValue(undefined)
|
|
2572
2589
|
recall.current = {
|
|
@@ -2576,44 +2593,44 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2576
2593
|
lastRecalled: safe,
|
|
2577
2594
|
}
|
|
2578
2595
|
historyConsumed()
|
|
2579
|
-
}, [historyFill, recallSpace, historyConsumed, resetCursorBlink])
|
|
2580
|
-
|
|
2581
|
-
useEffect(() => {
|
|
2582
|
-
setDraftImages((current) => {
|
|
2583
|
-
const next = current.filter(image => value.includes(image.marker))
|
|
2584
|
-
draftImagesRef.current = next
|
|
2585
|
-
return next.length === current.length ? current : next
|
|
2586
|
-
})
|
|
2587
|
-
}, [value])
|
|
2596
|
+
}, [historyFill, recallSpace, historyConsumed, resetCursorBlink])
|
|
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])
|
|
2588
2605
|
|
|
2589
2606
|
// Home/End and the Backspace-vs-Delete family never survive Ink's parser
|
|
2590
2607
|
// as distinct keys, and kitty CSI-u forms parse as unnamed junk Ink would
|
|
2591
2608
|
// insert as draft text.
|
|
2592
2609
|
// Patch stdin.read — the single choke point Ink's input loop pulls every
|
|
2593
|
-
// chunk through — to first rewrite decodable CSI-u sequences to their
|
|
2594
|
-
// legacy bytes, then tokenize editor-only sequences before Ink emits the
|
|
2595
|
-
// matching input event. Batched Home/End/Delete/Backspace actions remain
|
|
2596
|
-
// ordered even though Ink invokes useInput only once for the whole chunk.
|
|
2610
|
+
// chunk through — to first rewrite decodable CSI-u sequences to their
|
|
2611
|
+
// legacy bytes, then tokenize editor-only sequences before Ink emits the
|
|
2612
|
+
// matching input event. Batched Home/End/Delete/Backspace actions remain
|
|
2613
|
+
// ordered even though Ink invokes useInput only once for the whole chunk.
|
|
2597
2614
|
useEffect(() => {
|
|
2598
2615
|
if (stdin === undefined) return
|
|
2599
2616
|
const originalRead = stdin.read.bind(stdin)
|
|
2600
2617
|
const patchedRead = function patchedRead(this: typeof stdin, ...args: Parameters<typeof originalRead>) {
|
|
2601
|
-
const chunk = originalRead(...args)
|
|
2602
|
-
if (chunk === null) return chunk
|
|
2603
|
-
const normalized = normalizeKeyboardChunk(typeof chunk === 'string' ? chunk : String(chunk))
|
|
2604
|
-
const input = focusReporting
|
|
2605
|
-
? stripTerminalFocusEvents(normalized, focused => {
|
|
2606
|
-
terminalFocusedRef.current = focused
|
|
2607
|
-
})
|
|
2608
|
-
: normalized
|
|
2609
|
-
rawEditorTokens.current = tokenizeRawEditorChunk(input)
|
|
2610
|
-
return input
|
|
2618
|
+
const chunk = originalRead(...args)
|
|
2619
|
+
if (chunk === null) return chunk
|
|
2620
|
+
const normalized = normalizeKeyboardChunk(typeof chunk === 'string' ? chunk : String(chunk))
|
|
2621
|
+
const input = focusReporting
|
|
2622
|
+
? stripTerminalFocusEvents(normalized, focused => {
|
|
2623
|
+
terminalFocusedRef.current = focused
|
|
2624
|
+
})
|
|
2625
|
+
: normalized
|
|
2626
|
+
rawEditorTokens.current = tokenizeRawEditorChunk(input)
|
|
2627
|
+
return input
|
|
2611
2628
|
} as typeof stdin.read
|
|
2612
2629
|
stdin.read = patchedRead
|
|
2613
2630
|
return () => {
|
|
2614
2631
|
stdin.read = originalRead as typeof stdin.read
|
|
2615
2632
|
}
|
|
2616
|
-
}, [focusReporting, stdin])
|
|
2633
|
+
}, [focusReporting, stdin])
|
|
2617
2634
|
|
|
2618
2635
|
// Keep the navigation's recall space fresh while browsing state survives
|
|
2619
2636
|
// (new local submissions extend the space; the index stays valid unless
|
|
@@ -2636,112 +2653,112 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2636
2653
|
const mentionToken = tokenMatch === null
|
|
2637
2654
|
? undefined
|
|
2638
2655
|
: { start: beforeCursor.length - lastLine.length + (tokenMatch.index ?? 0) + (tokenMatch[1]?.length ?? 0), query: tokenMatch[2] ?? '' }
|
|
2639
|
-
const mentionActive = mentionToken !== undefined
|
|
2640
|
-
const [mentionRows, setMentionRows] = useState<readonly MentionCandidate[]>([])
|
|
2641
|
-
const mentionRequestRef = useRef(0)
|
|
2642
|
-
|
|
2643
|
-
const sameImagePath = (left: string, right: string): boolean => (
|
|
2644
|
-
process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right
|
|
2645
|
-
)
|
|
2646
|
-
|
|
2647
|
-
const uniqueImageMarker = (name: string, source: 'mention' | 'drop', reserved: readonly string[] = []): string => {
|
|
2648
|
-
const safeName = singleLineText(sanitizeDraftText(name))
|
|
2649
|
-
const base = source === 'mention' ? `@${safeName}` : `[image: ${safeName}]`
|
|
2650
|
-
let marker = base
|
|
2651
|
-
let suffix = 2
|
|
2652
|
-
while (valueRef.current.includes(marker) || draftImagesRef.current.some(image => image.marker === marker) || reserved.includes(marker)) {
|
|
2653
|
-
marker = source === 'mention' ? `@${safeName} (${suffix})` : `[image: ${safeName} ${suffix}]`
|
|
2654
|
-
suffix += 1
|
|
2655
|
-
}
|
|
2656
|
-
return marker
|
|
2657
|
-
}
|
|
2658
|
-
|
|
2659
|
-
const registerDraftImage = (inspection: ImagePathInspection, marker: string): boolean => {
|
|
2660
|
-
if (draftImagesRef.current.some(image => sameImagePath(image.path, inspection.path))) {
|
|
2661
|
-
notify(`${inspection.name} is already attached`, 'warning')
|
|
2662
|
-
return false
|
|
2663
|
-
}
|
|
2664
|
-
const next = [...draftImagesRef.current, { ...inspection, marker }]
|
|
2665
|
-
draftImagesRef.current = next
|
|
2666
|
-
setDraftImages(next)
|
|
2667
|
-
return true
|
|
2668
|
-
}
|
|
2669
|
-
|
|
2670
|
-
const insertDroppedImages = (paths: readonly string[]): void => {
|
|
2671
|
-
const originalValue = valueRef.current
|
|
2672
|
-
const originalCursor = cursorRef.current
|
|
2673
|
-
notify(`checking ${paths.length} image${paths.length === 1 ? '' : 's'}…`)
|
|
2674
|
-
void inspectImages(paths).then((inspected) => {
|
|
2675
|
-
const additions: DraftImage[] = []
|
|
2676
|
-
const markers: string[] = []
|
|
2677
|
-
for (const inspection of inspected) {
|
|
2678
|
-
if ([...draftImagesRef.current, ...additions].some(image => sameImagePath(image.path, inspection.path))) continue
|
|
2679
|
-
const marker = uniqueImageMarker(inspection.name, 'drop', markers)
|
|
2680
|
-
additions.push({ ...inspection, marker })
|
|
2681
|
-
markers.push(marker)
|
|
2682
|
-
}
|
|
2683
|
-
if (additions.length === 0) {
|
|
2684
|
-
notify('those images are already attached', 'warning')
|
|
2685
|
-
return
|
|
2686
|
-
}
|
|
2687
|
-
const current = valueRef.current
|
|
2688
|
-
const anchor = remapStableRange(originalValue, current, { start: originalCursor, end: originalCursor })
|
|
2689
|
-
if (anchor === undefined) {
|
|
2690
|
-
notify('draft changed at the image drop point; drop the images again', 'warning')
|
|
2691
|
-
return
|
|
2692
|
-
}
|
|
2693
|
-
const at = anchor.start
|
|
2694
|
-
const insertion = `${at > 0 && !/\s$/u.test(current.slice(0, at)) ? ' ' : ''}${markers.join(' ')}${current.slice(at) === '' ? '' : ' '}`
|
|
2695
|
-
const edit = replaceRangePreservingCursor(current, cursorRef.current, anchor, insertion)
|
|
2696
|
-
const nextCursor = current === originalValue && cursorRef.current === originalCursor
|
|
2697
|
-
? at + insertion.length
|
|
2698
|
-
: edit.cursor
|
|
2699
|
-
valueRef.current = edit.value
|
|
2700
|
-
cursorRef.current = nextCursor
|
|
2701
|
-
setValue(edit.value)
|
|
2702
|
-
setCursor(nextCursor)
|
|
2703
|
-
resetCursorBlink()
|
|
2704
|
-
const nextImages = [...draftImagesRef.current, ...additions]
|
|
2705
|
-
draftImagesRef.current = nextImages
|
|
2706
|
-
setDraftImages(nextImages)
|
|
2707
|
-
notify(`${additions.length} image${additions.length === 1 ? '' : 's'} ready for the next message`)
|
|
2708
|
-
}, (reason: unknown) => {
|
|
2709
|
-
notify(`image attachment failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
|
|
2710
|
-
})
|
|
2711
|
-
}
|
|
2712
|
-
|
|
2713
|
-
useEffect(() => {
|
|
2714
|
-
const requestId = mentionRequestRef.current + 1
|
|
2715
|
-
mentionRequestRef.current = requestId
|
|
2716
|
-
if (!active || !mentionActive) {
|
|
2717
|
-
setMentionRows([])
|
|
2718
|
-
return
|
|
2719
|
-
}
|
|
2720
|
-
const controller = new AbortController()
|
|
2721
|
-
const query = mentionToken.query
|
|
2722
|
-
const timer = setTimeout(() => {
|
|
2723
|
-
void loadMentions(query, controller.signal).then(
|
|
2724
|
-
rows => {
|
|
2725
|
-
if (!controller.signal.aborted && mentionRequestRef.current === requestId) setMentionRows(rows)
|
|
2726
|
-
},
|
|
2727
|
-
() => {},
|
|
2728
|
-
)
|
|
2729
|
-
}, 50)
|
|
2730
|
-
return () => {
|
|
2731
|
-
clearTimeout(timer)
|
|
2732
|
-
controller.abort()
|
|
2733
|
-
}
|
|
2734
|
-
}, [active, mentionActive, mentionToken?.query])
|
|
2656
|
+
const mentionActive = mentionToken !== undefined
|
|
2657
|
+
const [mentionRows, setMentionRows] = useState<readonly MentionCandidate[]>([])
|
|
2658
|
+
const mentionRequestRef = useRef(0)
|
|
2659
|
+
|
|
2660
|
+
const sameImagePath = (left: string, right: string): boolean => (
|
|
2661
|
+
process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right
|
|
2662
|
+
)
|
|
2663
|
+
|
|
2664
|
+
const uniqueImageMarker = (name: string, source: 'mention' | 'drop', reserved: readonly string[] = []): string => {
|
|
2665
|
+
const safeName = singleLineText(sanitizeDraftText(name))
|
|
2666
|
+
const base = source === 'mention' ? `@${safeName}` : `[image: ${safeName}]`
|
|
2667
|
+
let marker = base
|
|
2668
|
+
let suffix = 2
|
|
2669
|
+
while (valueRef.current.includes(marker) || draftImagesRef.current.some(image => image.marker === marker) || reserved.includes(marker)) {
|
|
2670
|
+
marker = source === 'mention' ? `@${safeName} (${suffix})` : `[image: ${safeName} ${suffix}]`
|
|
2671
|
+
suffix += 1
|
|
2672
|
+
}
|
|
2673
|
+
return marker
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
const registerDraftImage = (inspection: ImagePathInspection, marker: string): boolean => {
|
|
2677
|
+
if (draftImagesRef.current.some(image => sameImagePath(image.path, inspection.path))) {
|
|
2678
|
+
notify(`${inspection.name} is already attached`, 'warning')
|
|
2679
|
+
return false
|
|
2680
|
+
}
|
|
2681
|
+
const next = [...draftImagesRef.current, { ...inspection, marker }]
|
|
2682
|
+
draftImagesRef.current = next
|
|
2683
|
+
setDraftImages(next)
|
|
2684
|
+
return true
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
const insertDroppedImages = (paths: readonly string[]): void => {
|
|
2688
|
+
const originalValue = valueRef.current
|
|
2689
|
+
const originalCursor = cursorRef.current
|
|
2690
|
+
notify(`checking ${paths.length} image${paths.length === 1 ? '' : 's'}…`)
|
|
2691
|
+
void inspectImages(paths).then((inspected) => {
|
|
2692
|
+
const additions: DraftImage[] = []
|
|
2693
|
+
const markers: string[] = []
|
|
2694
|
+
for (const inspection of inspected) {
|
|
2695
|
+
if ([...draftImagesRef.current, ...additions].some(image => sameImagePath(image.path, inspection.path))) continue
|
|
2696
|
+
const marker = uniqueImageMarker(inspection.name, 'drop', markers)
|
|
2697
|
+
additions.push({ ...inspection, marker })
|
|
2698
|
+
markers.push(marker)
|
|
2699
|
+
}
|
|
2700
|
+
if (additions.length === 0) {
|
|
2701
|
+
notify('those images are already attached', 'warning')
|
|
2702
|
+
return
|
|
2703
|
+
}
|
|
2704
|
+
const current = valueRef.current
|
|
2705
|
+
const anchor = remapStableRange(originalValue, current, { start: originalCursor, end: originalCursor })
|
|
2706
|
+
if (anchor === undefined) {
|
|
2707
|
+
notify('draft changed at the image drop point; drop the images again', 'warning')
|
|
2708
|
+
return
|
|
2709
|
+
}
|
|
2710
|
+
const at = anchor.start
|
|
2711
|
+
const insertion = `${at > 0 && !/\s$/u.test(current.slice(0, at)) ? ' ' : ''}${markers.join(' ')}${current.slice(at) === '' ? '' : ' '}`
|
|
2712
|
+
const edit = replaceRangePreservingCursor(current, cursorRef.current, anchor, insertion)
|
|
2713
|
+
const nextCursor = current === originalValue && cursorRef.current === originalCursor
|
|
2714
|
+
? at + insertion.length
|
|
2715
|
+
: edit.cursor
|
|
2716
|
+
valueRef.current = edit.value
|
|
2717
|
+
cursorRef.current = nextCursor
|
|
2718
|
+
setValue(edit.value)
|
|
2719
|
+
setCursor(nextCursor)
|
|
2720
|
+
resetCursorBlink()
|
|
2721
|
+
const nextImages = [...draftImagesRef.current, ...additions]
|
|
2722
|
+
draftImagesRef.current = nextImages
|
|
2723
|
+
setDraftImages(nextImages)
|
|
2724
|
+
notify(`${additions.length} image${additions.length === 1 ? '' : 's'} ready for the next message`)
|
|
2725
|
+
}, (reason: unknown) => {
|
|
2726
|
+
notify(`image attachment failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
|
|
2727
|
+
})
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
useEffect(() => {
|
|
2731
|
+
const requestId = mentionRequestRef.current + 1
|
|
2732
|
+
mentionRequestRef.current = requestId
|
|
2733
|
+
if (!active || !mentionActive) {
|
|
2734
|
+
setMentionRows([])
|
|
2735
|
+
return
|
|
2736
|
+
}
|
|
2737
|
+
const controller = new AbortController()
|
|
2738
|
+
const query = mentionToken.query
|
|
2739
|
+
const timer = setTimeout(() => {
|
|
2740
|
+
void loadMentions(query, controller.signal).then(
|
|
2741
|
+
rows => {
|
|
2742
|
+
if (!controller.signal.aborted && mentionRequestRef.current === requestId) setMentionRows(rows)
|
|
2743
|
+
},
|
|
2744
|
+
() => {},
|
|
2745
|
+
)
|
|
2746
|
+
}, 50)
|
|
2747
|
+
return () => {
|
|
2748
|
+
clearTimeout(timer)
|
|
2749
|
+
controller.abort()
|
|
2750
|
+
}
|
|
2751
|
+
}, [active, mentionActive, mentionToken?.query])
|
|
2735
2752
|
|
|
2736
2753
|
// Codex routes keys to the topmost surface first. Completion therefore
|
|
2737
2754
|
// remains available while a turn runs, and Esc dismisses it before the
|
|
2738
|
-
// same key is allowed to interrupt the turn.
|
|
2739
|
-
const menuActive = !preparingImages && (slashActive || mentionActive) && dismissedMenuValue !== value
|
|
2740
|
-
const visibleMentionRows = mentionToken !== undefined && isPathLikeMentionQuery(mentionToken.query)
|
|
2741
|
-
? mentionRows.filter(row => row.kind !== 'session')
|
|
2742
|
-
: mentionRows
|
|
2743
|
-
const menuRows: readonly CompletionCandidate[] = mentionActive
|
|
2744
|
-
? visibleMentionRows.map(row => ({
|
|
2755
|
+
// same key is allowed to interrupt the turn.
|
|
2756
|
+
const menuActive = !preparingImages && (slashActive || mentionActive) && dismissedMenuValue !== value
|
|
2757
|
+
const visibleMentionRows = mentionToken !== undefined && isPathLikeMentionQuery(mentionToken.query)
|
|
2758
|
+
? mentionRows.filter(row => row.kind !== 'session')
|
|
2759
|
+
: mentionRows
|
|
2760
|
+
const menuRows: readonly CompletionCandidate[] = mentionActive
|
|
2761
|
+
? visibleMentionRows.map(row => ({
|
|
2745
2762
|
label: row.label.startsWith('@')
|
|
2746
2763
|
? row.label
|
|
2747
2764
|
: `@${row.label}${row.kind === 'directory' ? '/' : ''}`,
|
|
@@ -2750,199 +2767,199 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2750
2767
|
}))
|
|
2751
2768
|
: candidates
|
|
2752
2769
|
|
|
2753
|
-
/** Accept the highlighted completion-menu candidate into the draft. */
|
|
2754
|
-
const acceptMenuCandidate = (): void => {
|
|
2755
|
-
if (mentionActive && mentionToken !== undefined) {
|
|
2756
|
-
if (visibleMentionRows.length === 0) return
|
|
2757
|
-
const row = visibleMentionRows[completionIndex % visibleMentionRows.length]
|
|
2758
|
-
if (row !== undefined) {
|
|
2759
|
-
if (row.kind === 'file' && row.path !== undefined && looksLikeImagePath(row.path)) {
|
|
2760
|
-
const tokenText = value.slice(mentionToken.start, cursor)
|
|
2761
|
-
const start = mentionToken.start
|
|
2762
|
-
const originalValue = value
|
|
2763
|
-
notify(`checking image ${basename(row.path)}…`)
|
|
2764
|
-
void inspectImages([row.path]).then((inspected) => {
|
|
2765
|
-
const inspection = inspected[0]
|
|
2766
|
-
if (inspection === undefined) return
|
|
2767
|
-
const current = valueRef.current
|
|
2768
|
-
const anchor = remapStableRange(originalValue, current, { start, end: start + tokenText.length })
|
|
2769
|
-
if (anchor === undefined || current.slice(anchor.start, anchor.end) !== tokenText) {
|
|
2770
|
-
notify('draft changed around the image mention; select it again', 'warning')
|
|
2771
|
-
return
|
|
2772
|
-
}
|
|
2773
|
-
if (draftImagesRef.current.some(image => sameImagePath(image.path, inspection.path))) {
|
|
2774
|
-
const edit = replaceRangePreservingCursor(current, cursorRef.current, anchor, '')
|
|
2775
|
-
valueRef.current = edit.value
|
|
2776
|
-
cursorRef.current = edit.cursor
|
|
2777
|
-
setValue(edit.value)
|
|
2778
|
-
setCursor(edit.cursor)
|
|
2779
|
-
resetCursorBlink()
|
|
2780
|
-
setDismissedMenuValue(edit.value)
|
|
2781
|
-
notify(`${inspection.name} is already attached`, 'warning')
|
|
2782
|
-
return
|
|
2783
|
-
}
|
|
2784
|
-
const marker = uniqueImageMarker(inspection.name, 'mention')
|
|
2785
|
-
const edit = replaceRangePreservingCursor(current, cursorRef.current, anchor, marker)
|
|
2786
|
-
valueRef.current = edit.value
|
|
2787
|
-
cursorRef.current = edit.cursor
|
|
2788
|
-
setValue(edit.value)
|
|
2789
|
-
setCursor(edit.cursor)
|
|
2790
|
-
resetCursorBlink()
|
|
2791
|
-
setDismissedMenuValue(edit.value)
|
|
2792
|
-
registerDraftImage(inspection, marker)
|
|
2793
|
-
notify(`${inspection.name} ready for the next message`)
|
|
2794
|
-
}, (reason: unknown) => {
|
|
2795
|
-
notify(`image attachment failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
|
|
2796
|
-
})
|
|
2797
|
-
setCompletionIndex(0)
|
|
2798
|
-
setDismissedMenuValue(undefined)
|
|
2799
|
-
return
|
|
2800
|
-
}
|
|
2801
|
-
// Session rows carry the canonical @[label](dsh-session:…) token;
|
|
2770
|
+
/** Accept the highlighted completion-menu candidate into the draft. */
|
|
2771
|
+
const acceptMenuCandidate = (): void => {
|
|
2772
|
+
if (mentionActive && mentionToken !== undefined) {
|
|
2773
|
+
if (visibleMentionRows.length === 0) return
|
|
2774
|
+
const row = visibleMentionRows[completionIndex % visibleMentionRows.length]
|
|
2775
|
+
if (row !== undefined) {
|
|
2776
|
+
if (row.kind === 'file' && row.path !== undefined && looksLikeImagePath(row.path)) {
|
|
2777
|
+
const tokenText = value.slice(mentionToken.start, cursor)
|
|
2778
|
+
const start = mentionToken.start
|
|
2779
|
+
const originalValue = value
|
|
2780
|
+
notify(`checking image ${basename(row.path)}…`)
|
|
2781
|
+
void inspectImages([row.path]).then((inspected) => {
|
|
2782
|
+
const inspection = inspected[0]
|
|
2783
|
+
if (inspection === undefined) return
|
|
2784
|
+
const current = valueRef.current
|
|
2785
|
+
const anchor = remapStableRange(originalValue, current, { start, end: start + tokenText.length })
|
|
2786
|
+
if (anchor === undefined || current.slice(anchor.start, anchor.end) !== tokenText) {
|
|
2787
|
+
notify('draft changed around the image mention; select it again', 'warning')
|
|
2788
|
+
return
|
|
2789
|
+
}
|
|
2790
|
+
if (draftImagesRef.current.some(image => sameImagePath(image.path, inspection.path))) {
|
|
2791
|
+
const edit = replaceRangePreservingCursor(current, cursorRef.current, anchor, '')
|
|
2792
|
+
valueRef.current = edit.value
|
|
2793
|
+
cursorRef.current = edit.cursor
|
|
2794
|
+
setValue(edit.value)
|
|
2795
|
+
setCursor(edit.cursor)
|
|
2796
|
+
resetCursorBlink()
|
|
2797
|
+
setDismissedMenuValue(edit.value)
|
|
2798
|
+
notify(`${inspection.name} is already attached`, 'warning')
|
|
2799
|
+
return
|
|
2800
|
+
}
|
|
2801
|
+
const marker = uniqueImageMarker(inspection.name, 'mention')
|
|
2802
|
+
const edit = replaceRangePreservingCursor(current, cursorRef.current, anchor, marker)
|
|
2803
|
+
valueRef.current = edit.value
|
|
2804
|
+
cursorRef.current = edit.cursor
|
|
2805
|
+
setValue(edit.value)
|
|
2806
|
+
setCursor(edit.cursor)
|
|
2807
|
+
resetCursorBlink()
|
|
2808
|
+
setDismissedMenuValue(edit.value)
|
|
2809
|
+
registerDraftImage(inspection, marker)
|
|
2810
|
+
notify(`${inspection.name} ready for the next message`)
|
|
2811
|
+
}, (reason: unknown) => {
|
|
2812
|
+
notify(`image attachment failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
|
|
2813
|
+
})
|
|
2814
|
+
setCompletionIndex(0)
|
|
2815
|
+
setDismissedMenuValue(undefined)
|
|
2816
|
+
return
|
|
2817
|
+
}
|
|
2818
|
+
// Session rows carry the canonical @[label](dsh-session:…) token;
|
|
2802
2819
|
// file rows insert `@path` (directories keep their trailing slash).
|
|
2803
|
-
const insertion = row.label.startsWith('@')
|
|
2804
|
-
? row.label
|
|
2805
|
-
: `@${row.label}${row.kind === 'directory' ? '/' : ''}`
|
|
2806
|
-
const nextValue = value.slice(0, mentionToken.start) + insertion + value.slice(cursor)
|
|
2807
|
-
const nextCursor = mentionToken.start + insertion.length
|
|
2808
|
-
valueRef.current = nextValue
|
|
2809
|
-
cursorRef.current = nextCursor
|
|
2810
|
-
setValue(nextValue)
|
|
2811
|
-
setCursor(nextCursor)
|
|
2812
|
-
resetCursorBlink()
|
|
2813
|
-
}
|
|
2814
|
-
} else {
|
|
2815
|
-
if (candidates.length === 0) return
|
|
2816
|
-
const candidate = candidates[completionIndex % candidates.length]
|
|
2817
|
-
if (candidate !== undefined) {
|
|
2818
|
-
const nextValue = `${candidate.label} `
|
|
2819
|
-
const nextCursor = candidate.label.length + 1
|
|
2820
|
-
valueRef.current = nextValue
|
|
2821
|
-
cursorRef.current = nextCursor
|
|
2822
|
-
setValue(nextValue)
|
|
2823
|
-
setCursor(nextCursor)
|
|
2824
|
-
resetCursorBlink()
|
|
2825
|
-
}
|
|
2820
|
+
const insertion = row.label.startsWith('@')
|
|
2821
|
+
? row.label
|
|
2822
|
+
: `@${row.label}${row.kind === 'directory' ? '/' : ''}`
|
|
2823
|
+
const nextValue = value.slice(0, mentionToken.start) + insertion + value.slice(cursor)
|
|
2824
|
+
const nextCursor = mentionToken.start + insertion.length
|
|
2825
|
+
valueRef.current = nextValue
|
|
2826
|
+
cursorRef.current = nextCursor
|
|
2827
|
+
setValue(nextValue)
|
|
2828
|
+
setCursor(nextCursor)
|
|
2829
|
+
resetCursorBlink()
|
|
2830
|
+
}
|
|
2831
|
+
} else {
|
|
2832
|
+
if (candidates.length === 0) return
|
|
2833
|
+
const candidate = candidates[completionIndex % candidates.length]
|
|
2834
|
+
if (candidate !== undefined) {
|
|
2835
|
+
const nextValue = `${candidate.label} `
|
|
2836
|
+
const nextCursor = candidate.label.length + 1
|
|
2837
|
+
valueRef.current = nextValue
|
|
2838
|
+
cursorRef.current = nextCursor
|
|
2839
|
+
setValue(nextValue)
|
|
2840
|
+
setCursor(nextCursor)
|
|
2841
|
+
resetCursorBlink()
|
|
2842
|
+
}
|
|
2826
2843
|
}
|
|
2827
2844
|
setCompletionIndex(0)
|
|
2828
2845
|
setDismissedMenuValue(undefined)
|
|
2829
2846
|
}
|
|
2830
2847
|
|
|
2831
2848
|
/** Apply one editor edit: draft, cursor, kill buffer, menu reset. */
|
|
2832
|
-
const applyEdit = (edit: EditResult): void => {
|
|
2833
|
-
if (edit.killed !== undefined && edit.killed !== '') killRef.current = edit.killed
|
|
2834
|
-
valueRef.current = edit.value
|
|
2835
|
-
cursorRef.current = edit.cursor
|
|
2836
|
-
setValue(edit.value)
|
|
2837
|
-
setCursor(edit.cursor)
|
|
2838
|
-
resetCursorBlink()
|
|
2849
|
+
const applyEdit = (edit: EditResult): void => {
|
|
2850
|
+
if (edit.killed !== undefined && edit.killed !== '') killRef.current = edit.killed
|
|
2851
|
+
valueRef.current = edit.value
|
|
2852
|
+
cursorRef.current = edit.cursor
|
|
2853
|
+
setValue(edit.value)
|
|
2854
|
+
setCursor(edit.cursor)
|
|
2855
|
+
resetCursorBlink()
|
|
2839
2856
|
preferredColumnRef.current = null
|
|
2840
2857
|
setCompletionIndex(0)
|
|
2841
2858
|
setDismissedMenuValue(undefined)
|
|
2842
2859
|
}
|
|
2843
2860
|
|
|
2844
2861
|
/** Move the cursor without editing; horizontal moves clear the column preference. */
|
|
2845
|
-
const moveCursorTo = (next: number): void => {
|
|
2846
|
-
resetCursorBlink()
|
|
2847
|
-
if (next === cursorRef.current) return
|
|
2848
|
-
cursorRef.current = next
|
|
2849
|
-
setCursor(next)
|
|
2850
|
-
preferredColumnRef.current = null
|
|
2851
|
-
}
|
|
2852
|
-
|
|
2853
|
-
/** Apply an ordered raw-key batch against one current draft snapshot. */
|
|
2854
|
-
const applyRawEditorTokens = (tokens: readonly RawEditorToken[]): void => {
|
|
2855
|
-
let nextValue = valueRef.current
|
|
2856
|
-
let nextCursor = cursorRef.current
|
|
2857
|
-
for (const token of tokens) {
|
|
2858
|
-
if (token.kind === 'text') {
|
|
2859
|
-
const edit = insertText(nextValue, nextCursor, token.text)
|
|
2860
|
-
nextValue = edit.value
|
|
2861
|
-
nextCursor = edit.cursor
|
|
2862
|
-
continue
|
|
2863
|
-
}
|
|
2864
|
-
if (token.kind === 'home') {
|
|
2865
|
-
nextCursor = moveToLineStart(nextValue, nextCursor, false)
|
|
2866
|
-
continue
|
|
2867
|
-
}
|
|
2868
|
-
if (token.kind === 'end') {
|
|
2869
|
-
nextCursor = moveToLineEnd(nextValue, nextCursor, false)
|
|
2870
|
-
continue
|
|
2871
|
-
}
|
|
2872
|
-
const edit = token.kind === 'delete-backward'
|
|
2873
|
-
? deleteBackward(nextValue, nextCursor)
|
|
2874
|
-
: token.kind === 'delete-word-backward'
|
|
2875
|
-
? deleteWordBackward(nextValue, nextCursor)
|
|
2876
|
-
: token.kind === 'delete-forward'
|
|
2877
|
-
? deleteForward(nextValue, nextCursor)
|
|
2878
|
-
: deleteWordForward(nextValue, nextCursor)
|
|
2879
|
-
if (edit.killed !== undefined && edit.killed !== '') killRef.current = edit.killed
|
|
2880
|
-
nextValue = edit.value
|
|
2881
|
-
nextCursor = edit.cursor
|
|
2882
|
-
}
|
|
2883
|
-
valueRef.current = nextValue
|
|
2884
|
-
cursorRef.current = nextCursor
|
|
2885
|
-
setValue(nextValue)
|
|
2886
|
-
setCursor(nextCursor)
|
|
2887
|
-
resetCursorBlink()
|
|
2888
|
-
preferredColumnRef.current = null
|
|
2889
|
-
setCompletionIndex(0)
|
|
2890
|
-
setDismissedMenuValue(undefined)
|
|
2891
|
-
}
|
|
2892
|
-
|
|
2893
|
-
const cancelImageSubmission = (): void => {
|
|
2894
|
-
prepareEpochRef.current += 1
|
|
2895
|
-
prepareAbortRef.current?.abort()
|
|
2896
|
-
prepareAbortRef.current = undefined
|
|
2897
|
-
setPreparingImages(false)
|
|
2898
|
-
dismissNotice()
|
|
2899
|
-
notify('image submission cancelled', 'warning')
|
|
2900
|
-
}
|
|
2901
|
-
|
|
2902
|
-
/** Move through visual rows first, then cross history at the true edge. */
|
|
2903
|
-
const navigateVertical = (direction: -1 | 1): void => {
|
|
2904
|
-
const currentValue = valueRef.current
|
|
2905
|
-
const currentCursor = cursorRef.current
|
|
2906
|
-
const model = editorModel(currentValue, editorColumns)
|
|
2907
|
-
const preferred = preferredColumnRef.current ?? caretSite(model, currentCursor).column
|
|
2908
|
-
const next = moveCursorVertically(model, currentCursor, preferred, direction)
|
|
2909
|
-
if (next !== currentCursor) {
|
|
2910
|
-
cursorRef.current = next
|
|
2911
|
-
setCursor(next)
|
|
2912
|
-
resetCursorBlink()
|
|
2913
|
-
preferredColumnRef.current = preferred
|
|
2914
|
-
return
|
|
2915
|
-
}
|
|
2916
|
-
if (recall.current.entries.length > 0
|
|
2917
|
-
&& shouldRecallNavigate(currentValue, currentCursor, recall.current.lastRecalled, direction)) {
|
|
2918
|
-
const step = direction < 0 ? recallOlder(recall.current, currentValue) : recallNewer(recall.current)
|
|
2919
|
-
recall.current = step.state
|
|
2920
|
-
if (step.entry !== undefined) {
|
|
2921
|
-
const safe = sanitizeDraftText(step.entry)
|
|
2922
|
-
valueRef.current = safe
|
|
2923
|
-
cursorRef.current = safe.length
|
|
2924
|
-
setValue(safe)
|
|
2925
|
-
setCursor(safe.length)
|
|
2926
|
-
preferredColumnRef.current = null
|
|
2927
|
-
setDismissedMenuValue(undefined)
|
|
2928
|
-
}
|
|
2929
|
-
}
|
|
2930
|
-
resetCursorBlink()
|
|
2931
|
-
}
|
|
2932
|
-
|
|
2933
|
-
useStableInput((input, key) => {
|
|
2934
|
-
// Modal ownership: approval/question/model dialogs consume all keys.
|
|
2935
|
-
if (!active) return
|
|
2936
|
-
// React may not have committed the previous Tab completion render before
|
|
2937
|
-
// the next terminal byte arrives. Read the synchronous editor refs so a
|
|
2938
|
-
// completion followed immediately by text edits never uses stale closure
|
|
2939
|
-
// state.
|
|
2940
|
-
const liveValue = valueRef.current
|
|
2941
|
-
const liveCursor = cursorRef.current
|
|
2942
|
-
if (preparingImages) {
|
|
2943
|
-
if (key.escape || (key.ctrl && input === 'c')) cancelImageSubmission()
|
|
2944
|
-
return
|
|
2945
|
-
}
|
|
2862
|
+
const moveCursorTo = (next: number): void => {
|
|
2863
|
+
resetCursorBlink()
|
|
2864
|
+
if (next === cursorRef.current) return
|
|
2865
|
+
cursorRef.current = next
|
|
2866
|
+
setCursor(next)
|
|
2867
|
+
preferredColumnRef.current = null
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
/** Apply an ordered raw-key batch against one current draft snapshot. */
|
|
2871
|
+
const applyRawEditorTokens = (tokens: readonly RawEditorToken[]): void => {
|
|
2872
|
+
let nextValue = valueRef.current
|
|
2873
|
+
let nextCursor = cursorRef.current
|
|
2874
|
+
for (const token of tokens) {
|
|
2875
|
+
if (token.kind === 'text') {
|
|
2876
|
+
const edit = insertText(nextValue, nextCursor, token.text)
|
|
2877
|
+
nextValue = edit.value
|
|
2878
|
+
nextCursor = edit.cursor
|
|
2879
|
+
continue
|
|
2880
|
+
}
|
|
2881
|
+
if (token.kind === 'home') {
|
|
2882
|
+
nextCursor = moveToLineStart(nextValue, nextCursor, false)
|
|
2883
|
+
continue
|
|
2884
|
+
}
|
|
2885
|
+
if (token.kind === 'end') {
|
|
2886
|
+
nextCursor = moveToLineEnd(nextValue, nextCursor, false)
|
|
2887
|
+
continue
|
|
2888
|
+
}
|
|
2889
|
+
const edit = token.kind === 'delete-backward'
|
|
2890
|
+
? deleteBackward(nextValue, nextCursor)
|
|
2891
|
+
: token.kind === 'delete-word-backward'
|
|
2892
|
+
? deleteWordBackward(nextValue, nextCursor)
|
|
2893
|
+
: token.kind === 'delete-forward'
|
|
2894
|
+
? deleteForward(nextValue, nextCursor)
|
|
2895
|
+
: deleteWordForward(nextValue, nextCursor)
|
|
2896
|
+
if (edit.killed !== undefined && edit.killed !== '') killRef.current = edit.killed
|
|
2897
|
+
nextValue = edit.value
|
|
2898
|
+
nextCursor = edit.cursor
|
|
2899
|
+
}
|
|
2900
|
+
valueRef.current = nextValue
|
|
2901
|
+
cursorRef.current = nextCursor
|
|
2902
|
+
setValue(nextValue)
|
|
2903
|
+
setCursor(nextCursor)
|
|
2904
|
+
resetCursorBlink()
|
|
2905
|
+
preferredColumnRef.current = null
|
|
2906
|
+
setCompletionIndex(0)
|
|
2907
|
+
setDismissedMenuValue(undefined)
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
const cancelImageSubmission = (): void => {
|
|
2911
|
+
prepareEpochRef.current += 1
|
|
2912
|
+
prepareAbortRef.current?.abort()
|
|
2913
|
+
prepareAbortRef.current = undefined
|
|
2914
|
+
setPreparingImages(false)
|
|
2915
|
+
dismissNotice()
|
|
2916
|
+
notify('image submission cancelled', 'warning')
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2919
|
+
/** Move through visual rows first, then cross history at the true edge. */
|
|
2920
|
+
const navigateVertical = (direction: -1 | 1): void => {
|
|
2921
|
+
const currentValue = valueRef.current
|
|
2922
|
+
const currentCursor = cursorRef.current
|
|
2923
|
+
const model = editorModel(currentValue, editorColumns)
|
|
2924
|
+
const preferred = preferredColumnRef.current ?? caretSite(model, currentCursor).column
|
|
2925
|
+
const next = moveCursorVertically(model, currentCursor, preferred, direction)
|
|
2926
|
+
if (next !== currentCursor) {
|
|
2927
|
+
cursorRef.current = next
|
|
2928
|
+
setCursor(next)
|
|
2929
|
+
resetCursorBlink()
|
|
2930
|
+
preferredColumnRef.current = preferred
|
|
2931
|
+
return
|
|
2932
|
+
}
|
|
2933
|
+
if (recall.current.entries.length > 0
|
|
2934
|
+
&& shouldRecallNavigate(currentValue, currentCursor, recall.current.lastRecalled, direction)) {
|
|
2935
|
+
const step = direction < 0 ? recallOlder(recall.current, currentValue) : recallNewer(recall.current)
|
|
2936
|
+
recall.current = step.state
|
|
2937
|
+
if (step.entry !== undefined) {
|
|
2938
|
+
const safe = sanitizeDraftText(step.entry)
|
|
2939
|
+
valueRef.current = safe
|
|
2940
|
+
cursorRef.current = safe.length
|
|
2941
|
+
setValue(safe)
|
|
2942
|
+
setCursor(safe.length)
|
|
2943
|
+
preferredColumnRef.current = null
|
|
2944
|
+
setDismissedMenuValue(undefined)
|
|
2945
|
+
}
|
|
2946
|
+
}
|
|
2947
|
+
resetCursorBlink()
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
useStableInput((input, key) => {
|
|
2951
|
+
// Modal ownership: approval/question/model dialogs consume all keys.
|
|
2952
|
+
if (!active) return
|
|
2953
|
+
// React may not have committed the previous Tab completion render before
|
|
2954
|
+
// the next terminal byte arrives. Read the synchronous editor refs so a
|
|
2955
|
+
// completion followed immediately by text edits never uses stale closure
|
|
2956
|
+
// state.
|
|
2957
|
+
const liveValue = valueRef.current
|
|
2958
|
+
const liveCursor = cursorRef.current
|
|
2959
|
+
if (preparingImages) {
|
|
2960
|
+
if (key.escape || (key.ctrl && input === 'c')) cancelImageSubmission()
|
|
2961
|
+
return
|
|
2962
|
+
}
|
|
2946
2963
|
// Deletion confirm owns the box: y proceeds, anything else cancels.
|
|
2947
2964
|
// Typed in the INPUT BOX (codex delete-confirm): the keystroke is echoed
|
|
2948
2965
|
// as the box's own prompt, not an invisible panel keypress.
|
|
@@ -2964,11 +2981,13 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2964
2981
|
}
|
|
2965
2982
|
return
|
|
2966
2983
|
}
|
|
2967
|
-
// Ctrl+R toggles the thinking display (Claude-Code reasoning fold).
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
return
|
|
2984
|
+
// Ctrl+R toggles the thinking display (Claude-Code reasoning fold).
|
|
2985
|
+
// Alt+R is the zero-config alias: VS Code never intercepts Alt chords,
|
|
2986
|
+
// so the toggle stays reachable before /vscode-keys has been applied.
|
|
2987
|
+
if ((key.ctrl || key.meta) && input === 'r') {
|
|
2988
|
+
if (focusReporting && !terminalFocusedRef.current) return
|
|
2989
|
+
toggleReasoning()
|
|
2990
|
+
return
|
|
2972
2991
|
}
|
|
2973
2992
|
// Ctrl+O opens the bounded transcript inspector (Claude-Code convention,
|
|
2974
2993
|
// adapted to append-only static rows): one history entry at a time with
|
|
@@ -2981,16 +3000,16 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
2981
3000
|
// cancelled, a non-empty draft is cleared, and only an idle empty input
|
|
2982
3001
|
// exits. Ctrl+D always means exit but refuses mid-turn.
|
|
2983
3002
|
if (key.ctrl && input === 'c') {
|
|
2984
|
-
if (busy) {
|
|
2985
|
-
interrupt()
|
|
2986
|
-
} else if (liveValue !== '') {
|
|
2987
|
-
valueRef.current = ''
|
|
2988
|
-
cursorRef.current = 0
|
|
2989
|
-
setValue('')
|
|
2990
|
-
setCursor(0)
|
|
2991
|
-
resetCursorBlink()
|
|
2992
|
-
draftImagesRef.current = []
|
|
2993
|
-
setDraftImages([])
|
|
3003
|
+
if (busy) {
|
|
3004
|
+
interrupt()
|
|
3005
|
+
} else if (liveValue !== '') {
|
|
3006
|
+
valueRef.current = ''
|
|
3007
|
+
cursorRef.current = 0
|
|
3008
|
+
setValue('')
|
|
3009
|
+
setCursor(0)
|
|
3010
|
+
resetCursorBlink()
|
|
3011
|
+
draftImagesRef.current = []
|
|
3012
|
+
setDraftImages([])
|
|
2994
3013
|
setCompletionIndex(0)
|
|
2995
3014
|
setDismissedMenuValue(undefined)
|
|
2996
3015
|
} else {
|
|
@@ -3001,8 +3020,8 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3001
3020
|
if (key.ctrl && input === 'd') {
|
|
3002
3021
|
// Codex: Ctrl+D deletes forward while a draft exists; the app-level
|
|
3003
3022
|
// exit only fires from an empty composer.
|
|
3004
|
-
if (liveValue !== '') {
|
|
3005
|
-
applyEdit(deleteForward(liveValue, liveCursor))
|
|
3023
|
+
if (liveValue !== '') {
|
|
3024
|
+
applyEdit(deleteForward(liveValue, liveCursor))
|
|
3006
3025
|
return
|
|
3007
3026
|
}
|
|
3008
3027
|
if (busy) notify('cancel the running turn before exiting (Esc or Ctrl+C)', 'warning')
|
|
@@ -3011,7 +3030,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3011
3030
|
}
|
|
3012
3031
|
if (key.escape) {
|
|
3013
3032
|
if (menuActive) {
|
|
3014
|
-
setDismissedMenuValue(liveValue)
|
|
3033
|
+
setDismissedMenuValue(liveValue)
|
|
3015
3034
|
return
|
|
3016
3035
|
}
|
|
3017
3036
|
if (hasNotice) {
|
|
@@ -3023,14 +3042,14 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3023
3042
|
}
|
|
3024
3043
|
// Delete on the empty composer cancels the newest queued message (the
|
|
3025
3044
|
// web queue-mirror contract: the durable splice drops the pending row).
|
|
3026
|
-
if (key.delete && liveValue === '' && queued.length > 0) {
|
|
3045
|
+
if (key.delete && liveValue === '' && queued.length > 0) {
|
|
3027
3046
|
cancelQueued(queued[queued.length - 1]!.messageId)
|
|
3028
3047
|
return
|
|
3029
3048
|
}
|
|
3030
3049
|
if (key.return) {
|
|
3031
3050
|
// A newline inside an open bracketed paste inserts; it never submits.
|
|
3032
3051
|
if (pasteBracketRef.current) {
|
|
3033
|
-
applyEdit(insertText(liveValue, liveCursor, '\n'))
|
|
3052
|
+
applyEdit(insertText(liveValue, liveCursor, '\n'))
|
|
3034
3053
|
return
|
|
3035
3054
|
}
|
|
3036
3055
|
// Enter on an open completion menu accepts the highlighted candidate
|
|
@@ -3039,55 +3058,55 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3039
3058
|
// exactly, in which case Enter submits it (typing a full "/effort" and
|
|
3040
3059
|
// pressing return must run the command, not re-accept its own text).
|
|
3041
3060
|
if (menuActive) {
|
|
3042
|
-
const exactSlash = !mentionActive && candidates.some(candidate => candidate.label === liveValue)
|
|
3061
|
+
const exactSlash = !mentionActive && candidates.some(candidate => candidate.label === liveValue)
|
|
3043
3062
|
if (!exactSlash) {
|
|
3044
3063
|
acceptMenuCandidate()
|
|
3045
3064
|
return
|
|
3046
3065
|
}
|
|
3047
|
-
}
|
|
3048
|
-
const text = liveValue.trim()
|
|
3049
|
-
if (draftImagesRef.current.length > 0) {
|
|
3050
|
-
const controller = new AbortController()
|
|
3051
|
-
const epoch = prepareEpochRef.current + 1
|
|
3052
|
-
prepareEpochRef.current = epoch
|
|
3053
|
-
prepareAbortRef.current = controller
|
|
3054
|
-
setPreparingImages(true)
|
|
3055
|
-
notify(`processing ${draftImagesRef.current.length} image${draftImagesRef.current.length === 1 ? '' : 's'}…`)
|
|
3056
|
-
const snapshot = draftImagesRef.current
|
|
3057
|
-
void prepareImages(snapshot.map(image => image.path), controller.signal).then((images) => {
|
|
3058
|
-
if (controller.signal.aborted || prepareEpochRef.current !== epoch) return
|
|
3059
|
-
prepareAbortRef.current = undefined
|
|
3060
|
-
setPreparingImages(false)
|
|
3061
|
-
valueRef.current = ''
|
|
3062
|
-
cursorRef.current = 0
|
|
3063
|
-
setValue('')
|
|
3064
|
-
setCursor(0)
|
|
3065
|
-
draftImagesRef.current = []
|
|
3066
|
-
setDraftImages([])
|
|
3067
|
-
setCompletionIndex(0)
|
|
3068
|
-
setDismissedMenuValue(undefined)
|
|
3069
|
-
dismissNotice()
|
|
3070
|
-
if (text !== '') {
|
|
3071
|
-
recordLocal(text)
|
|
3072
|
-
recordHistory(text)
|
|
3073
|
-
}
|
|
3074
|
-
recall.current = beginRecall(recallSpace, '')
|
|
3075
|
-
if (busy) steer(text, images)
|
|
3076
|
-
else dispatch(text, images)
|
|
3077
|
-
}, (reason: unknown) => {
|
|
3078
|
-
if (controller.signal.aborted || prepareEpochRef.current !== epoch) return
|
|
3079
|
-
prepareAbortRef.current = undefined
|
|
3080
|
-
setPreparingImages(false)
|
|
3081
|
-
notify(`image submission failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
|
|
3082
|
-
})
|
|
3083
|
-
return
|
|
3084
|
-
}
|
|
3085
|
-
valueRef.current = ''
|
|
3086
|
-
cursorRef.current = 0
|
|
3087
|
-
setValue('')
|
|
3088
|
-
setCursor(0)
|
|
3089
|
-
resetCursorBlink()
|
|
3090
|
-
setCompletionIndex(0)
|
|
3066
|
+
}
|
|
3067
|
+
const text = liveValue.trim()
|
|
3068
|
+
if (draftImagesRef.current.length > 0) {
|
|
3069
|
+
const controller = new AbortController()
|
|
3070
|
+
const epoch = prepareEpochRef.current + 1
|
|
3071
|
+
prepareEpochRef.current = epoch
|
|
3072
|
+
prepareAbortRef.current = controller
|
|
3073
|
+
setPreparingImages(true)
|
|
3074
|
+
notify(`processing ${draftImagesRef.current.length} image${draftImagesRef.current.length === 1 ? '' : 's'}…`)
|
|
3075
|
+
const snapshot = draftImagesRef.current
|
|
3076
|
+
void prepareImages(snapshot.map(image => image.path), controller.signal).then((images) => {
|
|
3077
|
+
if (controller.signal.aborted || prepareEpochRef.current !== epoch) return
|
|
3078
|
+
prepareAbortRef.current = undefined
|
|
3079
|
+
setPreparingImages(false)
|
|
3080
|
+
valueRef.current = ''
|
|
3081
|
+
cursorRef.current = 0
|
|
3082
|
+
setValue('')
|
|
3083
|
+
setCursor(0)
|
|
3084
|
+
draftImagesRef.current = []
|
|
3085
|
+
setDraftImages([])
|
|
3086
|
+
setCompletionIndex(0)
|
|
3087
|
+
setDismissedMenuValue(undefined)
|
|
3088
|
+
dismissNotice()
|
|
3089
|
+
if (text !== '') {
|
|
3090
|
+
recordLocal(text)
|
|
3091
|
+
recordHistory(text)
|
|
3092
|
+
}
|
|
3093
|
+
recall.current = beginRecall(recallSpace, '')
|
|
3094
|
+
if (busy) steer(text, images)
|
|
3095
|
+
else dispatch(text, images)
|
|
3096
|
+
}, (reason: unknown) => {
|
|
3097
|
+
if (controller.signal.aborted || prepareEpochRef.current !== epoch) return
|
|
3098
|
+
prepareAbortRef.current = undefined
|
|
3099
|
+
setPreparingImages(false)
|
|
3100
|
+
notify(`image submission failed: ${reason instanceof Error ? reason.message : String(reason)}`, 'error')
|
|
3101
|
+
})
|
|
3102
|
+
return
|
|
3103
|
+
}
|
|
3104
|
+
valueRef.current = ''
|
|
3105
|
+
cursorRef.current = 0
|
|
3106
|
+
setValue('')
|
|
3107
|
+
setCursor(0)
|
|
3108
|
+
resetCursorBlink()
|
|
3109
|
+
setCompletionIndex(0)
|
|
3091
3110
|
setDismissedMenuValue(undefined)
|
|
3092
3111
|
if (text === '') return
|
|
3093
3112
|
dismissNotice()
|
|
@@ -3213,6 +3232,13 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3213
3232
|
openTodos()
|
|
3214
3233
|
return
|
|
3215
3234
|
}
|
|
3235
|
+
if (text === '/vscode-keys' || text.startsWith('/vscode-keys ')) {
|
|
3236
|
+
void applyEditorKeys().then(
|
|
3237
|
+
summary => notify(summary),
|
|
3238
|
+
error => notify(`vscode-keys failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
|
|
3239
|
+
)
|
|
3240
|
+
return
|
|
3241
|
+
}
|
|
3216
3242
|
if (text === '/subagent') {
|
|
3217
3243
|
openSubagent()
|
|
3218
3244
|
return
|
|
@@ -3231,19 +3257,19 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3231
3257
|
dispatch(text)
|
|
3232
3258
|
return
|
|
3233
3259
|
}
|
|
3234
|
-
// Ink exposes Ctrl+J as a bare LF and Alt+Enter as a bare CR after
|
|
3235
|
-
// stripping the leading escape. Neither is a multiline shortcut.
|
|
3236
|
-
if (input === '\n' || input === '\r') return
|
|
3237
|
-
// A fast Tab followed by text can arrive as one readable chunk in an
|
|
3238
|
-
// integrated terminal. Accept the candidate first, then apply the
|
|
3239
|
-
// remaining characters against the synchronously updated editor refs.
|
|
3240
|
-
if (menuActive && (key.tab || input.startsWith('\t'))) {
|
|
3241
|
-
const remainder = key.tab ? '' : input.slice(1)
|
|
3242
|
-
acceptMenuCandidate()
|
|
3243
|
-
if (remainder !== '') applyEdit(insertText(valueRef.current, cursorRef.current, remainder))
|
|
3244
|
-
return
|
|
3245
|
-
}
|
|
3246
|
-
if (menuActive && key.upArrow) {
|
|
3260
|
+
// Ink exposes Ctrl+J as a bare LF and Alt+Enter as a bare CR after
|
|
3261
|
+
// stripping the leading escape. Neither is a multiline shortcut.
|
|
3262
|
+
if (input === '\n' || input === '\r') return
|
|
3263
|
+
// A fast Tab followed by text can arrive as one readable chunk in an
|
|
3264
|
+
// integrated terminal. Accept the candidate first, then apply the
|
|
3265
|
+
// remaining characters against the synchronously updated editor refs.
|
|
3266
|
+
if (menuActive && (key.tab || input.startsWith('\t'))) {
|
|
3267
|
+
const remainder = key.tab ? '' : input.slice(1)
|
|
3268
|
+
acceptMenuCandidate()
|
|
3269
|
+
if (remainder !== '') applyEdit(insertText(valueRef.current, cursorRef.current, remainder))
|
|
3270
|
+
return
|
|
3271
|
+
}
|
|
3272
|
+
if (menuActive && key.upArrow) {
|
|
3247
3273
|
setCompletionIndex(index => (index + menuRows.length - 1) % menuRows.length)
|
|
3248
3274
|
return
|
|
3249
3275
|
}
|
|
@@ -3251,86 +3277,86 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3251
3277
|
setCompletionIndex(index => (index + 1) % menuRows.length)
|
|
3252
3278
|
return
|
|
3253
3279
|
}
|
|
3254
|
-
// Batched Home/End/Delete/Backspace sequences bypass Ink's one-key parser
|
|
3255
|
-
// and reduce against one current editor snapshot in their original order.
|
|
3256
|
-
const rawTokens = rawEditorTokens.current
|
|
3257
|
-
rawEditorTokens.current = undefined
|
|
3258
|
-
if (rawTokens !== undefined) {
|
|
3259
|
-
applyRawEditorTokens(rawTokens)
|
|
3260
|
-
return
|
|
3261
|
-
}
|
|
3262
|
-
if (key.upArrow || key.downArrow) {
|
|
3263
|
-
navigateVertical(key.upArrow ? -1 : 1)
|
|
3264
|
-
return
|
|
3265
|
-
}
|
|
3280
|
+
// Batched Home/End/Delete/Backspace sequences bypass Ink's one-key parser
|
|
3281
|
+
// and reduce against one current editor snapshot in their original order.
|
|
3282
|
+
const rawTokens = rawEditorTokens.current
|
|
3283
|
+
rawEditorTokens.current = undefined
|
|
3284
|
+
if (rawTokens !== undefined) {
|
|
3285
|
+
applyRawEditorTokens(rawTokens)
|
|
3286
|
+
return
|
|
3287
|
+
}
|
|
3288
|
+
if (key.upArrow || key.downArrow) {
|
|
3289
|
+
navigateVertical(key.upArrow ? -1 : 1)
|
|
3290
|
+
return
|
|
3291
|
+
}
|
|
3266
3292
|
// Ctrl+P / Ctrl+N share the Up/Down contract (Codex binds them to
|
|
3267
3293
|
// move_up/move_down, so the history gate applies first).
|
|
3268
|
-
if (key.ctrl && (input === 'p' || input === 'n')) {
|
|
3269
|
-
navigateVertical(input === 'p' ? -1 : 1)
|
|
3270
|
-
return
|
|
3271
|
-
}
|
|
3272
|
-
// Codex editor keymap: Alt/Ctrl+arrows and Alt+B/F move by word pieces;
|
|
3273
|
-
// plain arrows and Ctrl+B/F move by grapheme.
|
|
3274
|
-
if (key.leftArrow) {
|
|
3275
|
-
moveCursorTo(key.meta || key.ctrl ? moveWordLeft(liveValue, liveCursor) : moveCursorBy(liveValue, liveCursor, -1))
|
|
3276
|
-
return
|
|
3277
|
-
}
|
|
3278
|
-
if (key.rightArrow) {
|
|
3279
|
-
moveCursorTo(key.meta || key.ctrl ? moveWordRight(liveValue, liveCursor) : moveCursorBy(liveValue, liveCursor, 1))
|
|
3280
|
-
return
|
|
3281
|
-
}
|
|
3282
|
-
if (key.meta && input === 'b') {
|
|
3283
|
-
moveCursorTo(moveWordLeft(liveValue, liveCursor))
|
|
3284
|
-
return
|
|
3285
|
-
}
|
|
3286
|
-
if (key.meta && input === 'f') {
|
|
3287
|
-
moveCursorTo(moveWordRight(liveValue, liveCursor))
|
|
3288
|
-
return
|
|
3289
|
-
}
|
|
3290
|
-
if (key.ctrl && input === 'b') {
|
|
3291
|
-
moveCursorTo(moveCursorBy(liveValue, liveCursor, -1))
|
|
3292
|
-
return
|
|
3293
|
-
}
|
|
3294
|
-
if (key.ctrl && input === 'f') {
|
|
3295
|
-
moveCursorTo(moveCursorBy(liveValue, liveCursor, 1))
|
|
3296
|
-
return
|
|
3297
|
-
}
|
|
3294
|
+
if (key.ctrl && (input === 'p' || input === 'n')) {
|
|
3295
|
+
navigateVertical(input === 'p' ? -1 : 1)
|
|
3296
|
+
return
|
|
3297
|
+
}
|
|
3298
|
+
// Codex editor keymap: Alt/Ctrl+arrows and Alt+B/F move by word pieces;
|
|
3299
|
+
// plain arrows and Ctrl+B/F move by grapheme.
|
|
3300
|
+
if (key.leftArrow) {
|
|
3301
|
+
moveCursorTo(key.meta || key.ctrl ? moveWordLeft(liveValue, liveCursor) : moveCursorBy(liveValue, liveCursor, -1))
|
|
3302
|
+
return
|
|
3303
|
+
}
|
|
3304
|
+
if (key.rightArrow) {
|
|
3305
|
+
moveCursorTo(key.meta || key.ctrl ? moveWordRight(liveValue, liveCursor) : moveCursorBy(liveValue, liveCursor, 1))
|
|
3306
|
+
return
|
|
3307
|
+
}
|
|
3308
|
+
if (key.meta && input === 'b') {
|
|
3309
|
+
moveCursorTo(moveWordLeft(liveValue, liveCursor))
|
|
3310
|
+
return
|
|
3311
|
+
}
|
|
3312
|
+
if (key.meta && input === 'f') {
|
|
3313
|
+
moveCursorTo(moveWordRight(liveValue, liveCursor))
|
|
3314
|
+
return
|
|
3315
|
+
}
|
|
3316
|
+
if (key.ctrl && input === 'b') {
|
|
3317
|
+
moveCursorTo(moveCursorBy(liveValue, liveCursor, -1))
|
|
3318
|
+
return
|
|
3319
|
+
}
|
|
3320
|
+
if (key.ctrl && input === 'f') {
|
|
3321
|
+
moveCursorTo(moveCursorBy(liveValue, liveCursor, 1))
|
|
3322
|
+
return
|
|
3323
|
+
}
|
|
3298
3324
|
// Ctrl+W and Alt+Backspace delete the previous word piece into the kill
|
|
3299
|
-
// buffer; Alt+D and the raw Ctrl/Alt+Delete variants kill forward.
|
|
3300
|
-
if (key.ctrl && input === 'w') {
|
|
3301
|
-
applyEdit(deleteWordBackward(liveValue, liveCursor))
|
|
3302
|
-
return
|
|
3303
|
-
}
|
|
3304
|
-
if (key.meta && input === 'd') {
|
|
3305
|
-
applyEdit(deleteWordForward(liveValue, liveCursor))
|
|
3306
|
-
return
|
|
3325
|
+
// buffer; Alt+D and the raw Ctrl/Alt+Delete variants kill forward.
|
|
3326
|
+
if (key.ctrl && input === 'w') {
|
|
3327
|
+
applyEdit(deleteWordBackward(liveValue, liveCursor))
|
|
3328
|
+
return
|
|
3329
|
+
}
|
|
3330
|
+
if (key.meta && input === 'd') {
|
|
3331
|
+
applyEdit(deleteWordForward(liveValue, liveCursor))
|
|
3332
|
+
return
|
|
3307
3333
|
}
|
|
3308
3334
|
// Un-annotated backspace/delete (Ink maps both and here):
|
|
3309
|
-
// delete the grapheme before the cursor.
|
|
3310
|
-
if (key.backspace || key.delete) {
|
|
3311
|
-
applyEdit(deleteBackward(liveValue, liveCursor))
|
|
3312
|
-
return
|
|
3335
|
+
// delete the grapheme before the cursor.
|
|
3336
|
+
if (key.backspace || key.delete) {
|
|
3337
|
+
applyEdit(deleteBackward(liveValue, liveCursor))
|
|
3338
|
+
return
|
|
3313
3339
|
}
|
|
3314
3340
|
// Readline parity over the LOGICAL line: A/E to its ends, U/K kill to
|
|
3315
|
-
// them (filling the single kill buffer), Y yanks it back.
|
|
3316
|
-
if (key.ctrl && input === 'a') {
|
|
3317
|
-
moveCursorTo(moveToLineStart(liveValue, liveCursor, true))
|
|
3318
|
-
return
|
|
3319
|
-
}
|
|
3320
|
-
if (key.ctrl && input === 'e') {
|
|
3321
|
-
moveCursorTo(moveToLineEnd(liveValue, liveCursor, true))
|
|
3322
|
-
return
|
|
3323
|
-
}
|
|
3324
|
-
if (key.ctrl && input === 'u') {
|
|
3325
|
-
applyEdit(killToLineStart(liveValue, liveCursor))
|
|
3326
|
-
return
|
|
3327
|
-
}
|
|
3328
|
-
if (key.ctrl && input === 'k') {
|
|
3329
|
-
applyEdit(killToLineEnd(liveValue, liveCursor))
|
|
3330
|
-
return
|
|
3331
|
-
}
|
|
3332
|
-
if (key.ctrl && input === 'y') {
|
|
3333
|
-
if (killRef.current !== '') applyEdit(insertText(liveValue, liveCursor, killRef.current))
|
|
3341
|
+
// them (filling the single kill buffer), Y yanks it back.
|
|
3342
|
+
if (key.ctrl && input === 'a') {
|
|
3343
|
+
moveCursorTo(moveToLineStart(liveValue, liveCursor, true))
|
|
3344
|
+
return
|
|
3345
|
+
}
|
|
3346
|
+
if (key.ctrl && input === 'e') {
|
|
3347
|
+
moveCursorTo(moveToLineEnd(liveValue, liveCursor, true))
|
|
3348
|
+
return
|
|
3349
|
+
}
|
|
3350
|
+
if (key.ctrl && input === 'u') {
|
|
3351
|
+
applyEdit(killToLineStart(liveValue, liveCursor))
|
|
3352
|
+
return
|
|
3353
|
+
}
|
|
3354
|
+
if (key.ctrl && input === 'k') {
|
|
3355
|
+
applyEdit(killToLineEnd(liveValue, liveCursor))
|
|
3356
|
+
return
|
|
3357
|
+
}
|
|
3358
|
+
if (key.ctrl && input === 'y') {
|
|
3359
|
+
if (killRef.current !== '') applyEdit(insertText(liveValue, liveCursor, killRef.current))
|
|
3334
3360
|
return
|
|
3335
3361
|
}
|
|
3336
3362
|
// Ctrl+L refreshes the screen (readline convention): raw ANSI clear
|
|
@@ -3365,18 +3391,18 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3365
3391
|
pasteBracketRef.current = false
|
|
3366
3392
|
pasteBracketCancelRef.current?.()
|
|
3367
3393
|
text = text.replaceAll(PASTE_END_MARKER, '')
|
|
3368
|
-
}
|
|
3369
|
-
if (text === '') return
|
|
3370
|
-
const droppedPaths = text.length > 1 ? parsePastedImagePaths(text) : []
|
|
3371
|
-
if (droppedPaths.length > 0) {
|
|
3372
|
-
insertDroppedImages(droppedPaths)
|
|
3373
|
-
return
|
|
3374
|
-
}
|
|
3375
|
-
applyEdit(insertText(valueRef.current, cursorRef.current, text))
|
|
3376
|
-
}
|
|
3377
|
-
}, active)
|
|
3378
|
-
|
|
3379
|
-
// The DeepSeek easter-egg wave owns its 33ms tick HERE instead of in App:
|
|
3394
|
+
}
|
|
3395
|
+
if (text === '') return
|
|
3396
|
+
const droppedPaths = text.length > 1 ? parsePastedImagePaths(text) : []
|
|
3397
|
+
if (droppedPaths.length > 0) {
|
|
3398
|
+
insertDroppedImages(droppedPaths)
|
|
3399
|
+
return
|
|
3400
|
+
}
|
|
3401
|
+
applyEdit(insertText(valueRef.current, cursorRef.current, text))
|
|
3402
|
+
}
|
|
3403
|
+
}, active)
|
|
3404
|
+
|
|
3405
|
+
// The DeepSeek easter-egg wave owns its 33ms tick HERE instead of in App:
|
|
3380
3406
|
// the interval re-renders only the composer band at 30fps, never the whole
|
|
3381
3407
|
// tree. App drives the tier/style pair on a model switch; this local effect
|
|
3382
3408
|
// starts the sweep whenever that pair changes (App picks a NEW random style
|
|
@@ -3396,7 +3422,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3396
3422
|
setWaveTick(0)
|
|
3397
3423
|
}
|
|
3398
3424
|
}, [waveTier, waveStyle])
|
|
3399
|
-
const waveActive = !preparingImages && waveTick !== null && waveTier !== null && waveStyle !== null
|
|
3425
|
+
const waveActive = !preparingImages && waveTick !== null && waveTier !== null && waveStyle !== null
|
|
3400
3426
|
&& waveTick * DEEPSEEK_WAVE_TICK_MS < deepseekWaveDuration(waveTier, waveStyle)
|
|
3401
3427
|
useEffect(() => {
|
|
3402
3428
|
if (!waveActive) return
|
|
@@ -3422,7 +3448,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3422
3448
|
// column-safe physical rows, with the caret mapped to its exact row and
|
|
3423
3449
|
// column. Computed before the frozen path so the row report below runs
|
|
3424
3450
|
// unconditionally.
|
|
3425
|
-
const editorViewModel = editorModel(value, editorColumns)
|
|
3451
|
+
const editorViewModel = editorModel(value, editorColumns)
|
|
3426
3452
|
const clampedCursor = clampCursor(value, cursor)
|
|
3427
3453
|
const caret = caretSite(editorViewModel, clampedCursor)
|
|
3428
3454
|
const editorWindowRows = Math.min(editorViewModel.rows.length, Math.max(1, maxRows))
|
|
@@ -3448,7 +3474,7 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3448
3474
|
const bandWidth = Math.max(1, columns - 1)
|
|
3449
3475
|
const bandBg = inkColor(getPalette().composerBand)
|
|
3450
3476
|
const bandFill = (consumed: number): string => ' '.repeat(Math.max(0, bandWidth - consumed))
|
|
3451
|
-
const band = (content: ReactElement): ReactElement => createElement(
|
|
3477
|
+
const band = (content: ReactElement): ReactElement => createElement(
|
|
3452
3478
|
Box,
|
|
3453
3479
|
{ flexDirection: 'column', width: bandWidth },
|
|
3454
3480
|
createElement(Text, { backgroundColor: bandBg }, ' '.repeat(bandWidth)),
|
|
@@ -3486,138 +3512,138 @@ function Input({ active, frozen, busy, descriptors, skills, dispatch, steer, int
|
|
|
3486
3512
|
rows: menuRows,
|
|
3487
3513
|
})
|
|
3488
3514
|
|
|
3489
|
-
// Every state reuses this exact multiline editor window. Only the caret row
|
|
3490
|
-
// owns an inverse block; non-caret rows render their text without a hidden
|
|
3491
|
-
// spacer or a second blink timer.
|
|
3492
|
-
const editorRows: ReactElement[] = []
|
|
3493
|
-
for (let index = editorWindowStart; index < Math.min(editorViewModel.rows.length, editorWindowStart + editorWindowRows); index += 1) {
|
|
3494
|
-
const row = editorViewModel.rows[index]!
|
|
3495
|
-
const parts = editorRowParts(row, index, caret.row, clampedCursor, !preparingImages)
|
|
3496
|
-
const placeholder = index === 0 && value === '' && !busy && !preparingImages
|
|
3497
|
-
const tail = placeholder ? COMPOSER_PLACEHOLDER : parts.after
|
|
3498
|
-
const consumed = 2 + visibleColumns(parts.before) + visibleColumns(parts.caret) + visibleColumns(tail)
|
|
3499
|
-
editorRows.push(createElement(
|
|
3500
|
-
Text,
|
|
3501
|
-
{ key: index, backgroundColor: bandBg, wrap: 'truncate-end' },
|
|
3502
|
-
index === 0
|
|
3503
|
-
? preparingImages
|
|
3504
|
-
? createElement(Text, { color: inkColor(getPalette().warn), bold: true }, '… ')
|
|
3505
|
-
: busy
|
|
3506
|
-
? createElement(BusyChase)
|
|
3507
|
-
: createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, `${promptGlyph} `)
|
|
3508
|
-
: ' ',
|
|
3509
|
-
parts.before,
|
|
3510
|
-
parts.hasCaret
|
|
3511
|
-
? createElement(Text, { key: 'caret', inverse: cursorVisible || undefined }, parts.caret)
|
|
3512
|
-
: null,
|
|
3513
|
-
placeholder
|
|
3514
|
-
? createElement(Text, { dimColor: true }, COMPOSER_PLACEHOLDER)
|
|
3515
|
-
: parts.after,
|
|
3515
|
+
// Every state reuses this exact multiline editor window. Only the caret row
|
|
3516
|
+
// owns an inverse block; non-caret rows render their text without a hidden
|
|
3517
|
+
// spacer or a second blink timer.
|
|
3518
|
+
const editorRows: ReactElement[] = []
|
|
3519
|
+
for (let index = editorWindowStart; index < Math.min(editorViewModel.rows.length, editorWindowStart + editorWindowRows); index += 1) {
|
|
3520
|
+
const row = editorViewModel.rows[index]!
|
|
3521
|
+
const parts = editorRowParts(row, index, caret.row, clampedCursor, !preparingImages)
|
|
3522
|
+
const placeholder = index === 0 && value === '' && !busy && !preparingImages
|
|
3523
|
+
const tail = placeholder ? COMPOSER_PLACEHOLDER : parts.after
|
|
3524
|
+
const consumed = 2 + visibleColumns(parts.before) + visibleColumns(parts.caret) + visibleColumns(tail)
|
|
3525
|
+
editorRows.push(createElement(
|
|
3526
|
+
Text,
|
|
3527
|
+
{ key: index, backgroundColor: bandBg, wrap: 'truncate-end' },
|
|
3528
|
+
index === 0
|
|
3529
|
+
? preparingImages
|
|
3530
|
+
? createElement(Text, { color: inkColor(getPalette().warn), bold: true }, '… ')
|
|
3531
|
+
: busy
|
|
3532
|
+
? createElement(BusyChase)
|
|
3533
|
+
: createElement(Text, { color: promptColor, bold: tierActive ? true : undefined }, `${promptGlyph} `)
|
|
3534
|
+
: ' ',
|
|
3535
|
+
parts.before,
|
|
3536
|
+
parts.hasCaret
|
|
3537
|
+
? createElement(Text, { key: 'caret', inverse: cursorVisible || undefined }, parts.caret)
|
|
3538
|
+
: null,
|
|
3539
|
+
placeholder
|
|
3540
|
+
? createElement(Text, { dimColor: true }, COMPOSER_PLACEHOLDER)
|
|
3541
|
+
: parts.after,
|
|
3516
3542
|
bandFill(consumed),
|
|
3517
3543
|
))
|
|
3518
|
-
}
|
|
3519
|
-
const staticEditor = createElement(Box, { flexDirection: 'column' }, ...editorRows)
|
|
3520
|
-
|
|
3521
|
-
// The wave paints the SAME visible rows and caret site as the static path.
|
|
3522
|
-
// Graphemes remain atomic and every background sample advances by terminal
|
|
3523
|
-
// display columns, so CJK and emoji cannot move the caret or wrap the band.
|
|
3524
|
-
const waveRow = (): ReactElement => {
|
|
3525
|
-
const hues = deepseekWaveHues(waveTier!)
|
|
3526
|
-
const style = waveStyle!
|
|
3527
|
-
const bandRgb = getPalette().composerBand
|
|
3528
|
-
const visibleRows = editorViewModel.rows.slice(editorWindowStart, editorWindowStart + editorWindowRows)
|
|
3529
|
-
const totalBandRows = visibleRows.length + 2
|
|
3530
|
-
const waveBg = (row: number, column: number): string => {
|
|
3531
|
-
const rgb = deepseekWaveColumnBg(waveTick!, column, bandWidth, waveTier!, style, hues, bandRgb, row, totalBandRows)
|
|
3532
|
-
return rgb === null ? bandBg : inkColor(rgb)
|
|
3533
|
-
}
|
|
3534
|
-
const blankBandRow = (row: number): ReactElement => {
|
|
3535
|
-
const blanks: ComposerCell[] = []
|
|
3536
|
-
for (let column = 0; column < bandWidth; column += 1) {
|
|
3537
|
-
blanks.push({ char: ' ', width: 1, backgroundColor: waveBg(row, column) })
|
|
3538
|
-
}
|
|
3539
|
-
return createElement(Text, { key: `blank-${row}` }, ...waveRowSpans(blanks))
|
|
3540
|
-
}
|
|
3541
|
-
const cellIndexAtColumn = (cells: readonly ComposerCell[], target: number): number | undefined => {
|
|
3542
|
-
let column = 0
|
|
3543
|
-
for (let index = 0; index < cells.length; index += 1) {
|
|
3544
|
-
if (column === target) return index
|
|
3545
|
-
column += cells[index]!.width ?? visibleColumns(cells[index]!.char)
|
|
3546
|
-
if (column > target) return undefined
|
|
3547
|
-
}
|
|
3548
|
-
return undefined
|
|
3549
|
-
}
|
|
3550
|
-
const editorWaveRows = visibleRows.map((row, visibleIndex) => {
|
|
3551
|
-
const sourceIndex = editorWindowStart + visibleIndex
|
|
3552
|
-
const bandRow = visibleIndex + 1
|
|
3553
|
-
const parts = editorRowParts(row, sourceIndex, caret.row, clampedCursor)
|
|
3554
|
-
const placeholder = sourceIndex === 0 && value === '' && !busy
|
|
3555
|
-
const cells: ComposerCell[] = []
|
|
3556
|
-
let usedColumns = 0
|
|
3557
|
-
const push = (char: string, extra: Omit<ComposerCell, 'char' | 'width' | 'backgroundColor'> = {}): void => {
|
|
3558
|
-
const width = visibleColumns(char)
|
|
3559
|
-
cells.push({ char, width, backgroundColor: waveBg(bandRow, usedColumns), ...extra })
|
|
3560
|
-
usedColumns += width
|
|
3561
|
-
}
|
|
3562
|
-
if (sourceIndex === 0) {
|
|
3563
|
-
push(promptGlyph, { color: promptColor, bold: true })
|
|
3564
|
-
push(' ', { color: promptColor })
|
|
3565
|
-
} else {
|
|
3566
|
-
push(' ')
|
|
3567
|
-
push(' ')
|
|
3568
|
-
}
|
|
3569
|
-
for (const span of splitGraphemes(parts.before)) push(span.text)
|
|
3570
|
-
if (parts.hasCaret) push(parts.caret, { inverse: cursorVisible })
|
|
3571
|
-
const tail = placeholder ? COMPOSER_PLACEHOLDER : parts.after
|
|
3572
|
-
for (const span of splitGraphemes(tail)) push(span.text, placeholder ? { dim: true } : {})
|
|
3573
|
-
while (usedColumns < bandWidth) push(' ')
|
|
3574
|
-
|
|
3575
|
-
const middleBandRow = Math.floor(totalBandRows / 2)
|
|
3576
|
-
if (bandRow === middleBandRow && deepseekWaveWordVisible(waveTick!, waveTier!, style)) {
|
|
3577
|
-
const word = waveTier === 'unknown' ? 'Into the Unknown' : 'deepseek'
|
|
3578
|
-
const start = Math.max(2, Math.floor((bandWidth - word.length) / 2))
|
|
3579
|
-
const indices = Array.from({ length: word.length }, (_, at) => cellIndexAtColumn(cells, start + at))
|
|
3580
|
-
if (indices.every(index => index !== undefined && (cells[index]!.char === ' ' || cells[index]!.dim === true))) {
|
|
3581
|
-
for (let at = 0; at < word.length; at += 1) {
|
|
3582
|
-
const cell = cells[indices[at]!]!
|
|
3583
|
-
cell.char = word[at]!
|
|
3584
|
-
cell.width = 1
|
|
3585
|
-
cell.color = inkColor(deepseekWaveWordHue(at, hues))
|
|
3586
|
-
cell.bold = true
|
|
3587
|
-
cell.dim = false
|
|
3588
|
-
}
|
|
3589
|
-
}
|
|
3590
|
-
}
|
|
3591
|
-
if (bandRow === middleBandRow && (waveTier === 'deepseek' || waveTier === 'unknown') && style === 'wave') {
|
|
3592
|
-
const spark = deepseekWaveSpark(waveTick!)
|
|
3593
|
-
const lastIndex = cellIndexAtColumn(cells, bandWidth - 1)
|
|
3594
|
-
if (spark !== null && lastIndex !== undefined && cells[lastIndex]!.char === ' ') {
|
|
3595
|
-
cells[lastIndex]!.char = spark
|
|
3596
|
-
cells[lastIndex]!.color = promptColor
|
|
3597
|
-
cells[lastIndex]!.bold = true
|
|
3598
|
-
cells[lastIndex]!.dim = false
|
|
3599
|
-
}
|
|
3600
|
-
}
|
|
3601
|
-
return createElement(Text, { key: `editor-${sourceIndex}`, wrap: 'truncate-end' }, ...waveRowSpans(cells))
|
|
3602
|
-
})
|
|
3603
|
-
return createElement(
|
|
3604
|
-
Box,
|
|
3605
|
-
{ flexDirection: 'column', width: bandWidth },
|
|
3606
|
-
blankBandRow(0),
|
|
3607
|
-
...editorWaveRows,
|
|
3608
|
-
blankBandRow(totalBandRows - 1),
|
|
3609
|
-
)
|
|
3610
|
-
}
|
|
3611
|
-
|
|
3612
|
-
return createElement(
|
|
3613
|
-
Box,
|
|
3614
|
-
{ flexDirection: 'column' },
|
|
3615
|
-
menu,
|
|
3616
|
-
waveTick !== null && waveTier !== null && waveStyle !== null && !busy && !preparingImages
|
|
3617
|
-
? waveRow()
|
|
3618
|
-
: band(staticEditor),
|
|
3619
|
-
)
|
|
3620
|
-
}
|
|
3544
|
+
}
|
|
3545
|
+
const staticEditor = createElement(Box, { flexDirection: 'column' }, ...editorRows)
|
|
3546
|
+
|
|
3547
|
+
// The wave paints the SAME visible rows and caret site as the static path.
|
|
3548
|
+
// Graphemes remain atomic and every background sample advances by terminal
|
|
3549
|
+
// display columns, so CJK and emoji cannot move the caret or wrap the band.
|
|
3550
|
+
const waveRow = (): ReactElement => {
|
|
3551
|
+
const hues = deepseekWaveHues(waveTier!)
|
|
3552
|
+
const style = waveStyle!
|
|
3553
|
+
const bandRgb = getPalette().composerBand
|
|
3554
|
+
const visibleRows = editorViewModel.rows.slice(editorWindowStart, editorWindowStart + editorWindowRows)
|
|
3555
|
+
const totalBandRows = visibleRows.length + 2
|
|
3556
|
+
const waveBg = (row: number, column: number): string => {
|
|
3557
|
+
const rgb = deepseekWaveColumnBg(waveTick!, column, bandWidth, waveTier!, style, hues, bandRgb, row, totalBandRows)
|
|
3558
|
+
return rgb === null ? bandBg : inkColor(rgb)
|
|
3559
|
+
}
|
|
3560
|
+
const blankBandRow = (row: number): ReactElement => {
|
|
3561
|
+
const blanks: ComposerCell[] = []
|
|
3562
|
+
for (let column = 0; column < bandWidth; column += 1) {
|
|
3563
|
+
blanks.push({ char: ' ', width: 1, backgroundColor: waveBg(row, column) })
|
|
3564
|
+
}
|
|
3565
|
+
return createElement(Text, { key: `blank-${row}` }, ...waveRowSpans(blanks))
|
|
3566
|
+
}
|
|
3567
|
+
const cellIndexAtColumn = (cells: readonly ComposerCell[], target: number): number | undefined => {
|
|
3568
|
+
let column = 0
|
|
3569
|
+
for (let index = 0; index < cells.length; index += 1) {
|
|
3570
|
+
if (column === target) return index
|
|
3571
|
+
column += cells[index]!.width ?? visibleColumns(cells[index]!.char)
|
|
3572
|
+
if (column > target) return undefined
|
|
3573
|
+
}
|
|
3574
|
+
return undefined
|
|
3575
|
+
}
|
|
3576
|
+
const editorWaveRows = visibleRows.map((row, visibleIndex) => {
|
|
3577
|
+
const sourceIndex = editorWindowStart + visibleIndex
|
|
3578
|
+
const bandRow = visibleIndex + 1
|
|
3579
|
+
const parts = editorRowParts(row, sourceIndex, caret.row, clampedCursor)
|
|
3580
|
+
const placeholder = sourceIndex === 0 && value === '' && !busy
|
|
3581
|
+
const cells: ComposerCell[] = []
|
|
3582
|
+
let usedColumns = 0
|
|
3583
|
+
const push = (char: string, extra: Omit<ComposerCell, 'char' | 'width' | 'backgroundColor'> = {}): void => {
|
|
3584
|
+
const width = visibleColumns(char)
|
|
3585
|
+
cells.push({ char, width, backgroundColor: waveBg(bandRow, usedColumns), ...extra })
|
|
3586
|
+
usedColumns += width
|
|
3587
|
+
}
|
|
3588
|
+
if (sourceIndex === 0) {
|
|
3589
|
+
push(promptGlyph, { color: promptColor, bold: true })
|
|
3590
|
+
push(' ', { color: promptColor })
|
|
3591
|
+
} else {
|
|
3592
|
+
push(' ')
|
|
3593
|
+
push(' ')
|
|
3594
|
+
}
|
|
3595
|
+
for (const span of splitGraphemes(parts.before)) push(span.text)
|
|
3596
|
+
if (parts.hasCaret) push(parts.caret, { inverse: cursorVisible })
|
|
3597
|
+
const tail = placeholder ? COMPOSER_PLACEHOLDER : parts.after
|
|
3598
|
+
for (const span of splitGraphemes(tail)) push(span.text, placeholder ? { dim: true } : {})
|
|
3599
|
+
while (usedColumns < bandWidth) push(' ')
|
|
3600
|
+
|
|
3601
|
+
const middleBandRow = Math.floor(totalBandRows / 2)
|
|
3602
|
+
if (bandRow === middleBandRow && deepseekWaveWordVisible(waveTick!, waveTier!, style)) {
|
|
3603
|
+
const word = waveTier === 'unknown' ? 'Into the Unknown' : 'deepseek'
|
|
3604
|
+
const start = Math.max(2, Math.floor((bandWidth - word.length) / 2))
|
|
3605
|
+
const indices = Array.from({ length: word.length }, (_, at) => cellIndexAtColumn(cells, start + at))
|
|
3606
|
+
if (indices.every(index => index !== undefined && (cells[index]!.char === ' ' || cells[index]!.dim === true))) {
|
|
3607
|
+
for (let at = 0; at < word.length; at += 1) {
|
|
3608
|
+
const cell = cells[indices[at]!]!
|
|
3609
|
+
cell.char = word[at]!
|
|
3610
|
+
cell.width = 1
|
|
3611
|
+
cell.color = inkColor(deepseekWaveWordHue(at, hues))
|
|
3612
|
+
cell.bold = true
|
|
3613
|
+
cell.dim = false
|
|
3614
|
+
}
|
|
3615
|
+
}
|
|
3616
|
+
}
|
|
3617
|
+
if (bandRow === middleBandRow && (waveTier === 'deepseek' || waveTier === 'unknown') && style === 'wave') {
|
|
3618
|
+
const spark = deepseekWaveSpark(waveTick!)
|
|
3619
|
+
const lastIndex = cellIndexAtColumn(cells, bandWidth - 1)
|
|
3620
|
+
if (spark !== null && lastIndex !== undefined && cells[lastIndex]!.char === ' ') {
|
|
3621
|
+
cells[lastIndex]!.char = spark
|
|
3622
|
+
cells[lastIndex]!.color = promptColor
|
|
3623
|
+
cells[lastIndex]!.bold = true
|
|
3624
|
+
cells[lastIndex]!.dim = false
|
|
3625
|
+
}
|
|
3626
|
+
}
|
|
3627
|
+
return createElement(Text, { key: `editor-${sourceIndex}`, wrap: 'truncate-end' }, ...waveRowSpans(cells))
|
|
3628
|
+
})
|
|
3629
|
+
return createElement(
|
|
3630
|
+
Box,
|
|
3631
|
+
{ flexDirection: 'column', width: bandWidth },
|
|
3632
|
+
blankBandRow(0),
|
|
3633
|
+
...editorWaveRows,
|
|
3634
|
+
blankBandRow(totalBandRows - 1),
|
|
3635
|
+
)
|
|
3636
|
+
}
|
|
3637
|
+
|
|
3638
|
+
return createElement(
|
|
3639
|
+
Box,
|
|
3640
|
+
{ flexDirection: 'column' },
|
|
3641
|
+
menu,
|
|
3642
|
+
waveTick !== null && waveTier !== null && waveStyle !== null && !busy && !preparingImages
|
|
3643
|
+
? waveRow()
|
|
3644
|
+
: band(staticEditor),
|
|
3645
|
+
)
|
|
3646
|
+
}
|
|
3621
3647
|
|
|
3622
3648
|
/** One cached settled row: the row Box plus its roomy-prompt spacers. */
|
|
3623
3649
|
interface SettledRowRecord {
|
|
@@ -3857,13 +3883,13 @@ export function App(props: AppProps): ReactElement {
|
|
|
3857
3883
|
const skills = useSyncExternalStore(props.skills.subscribe, readSkills)
|
|
3858
3884
|
const [modelLabel, setModelLabel] = useState(props.model)
|
|
3859
3885
|
const [modelOpen, setModelOpen] = useState(false)
|
|
3860
|
-
/** Nested /model stages; only one owns terminal input at a time. */
|
|
3861
|
-
const [providerOpen, setProviderOpen] = useState(false)
|
|
3862
|
-
const [providerAction, setProviderAction] = useState<
|
|
3863
|
-
| { kind: 'credential' | 'configure' | 'unset' | 'remove'; target: ProviderTargetView }
|
|
3864
|
-
| { kind: 'login' | 'logout'; target: ProviderTargetView; authorization: ProviderAuthorizationRow }
|
|
3865
|
-
| undefined
|
|
3866
|
-
>(undefined)
|
|
3886
|
+
/** Nested /model stages; only one owns terminal input at a time. */
|
|
3887
|
+
const [providerOpen, setProviderOpen] = useState(false)
|
|
3888
|
+
const [providerAction, setProviderAction] = useState<
|
|
3889
|
+
| { kind: 'credential' | 'configure' | 'unset' | 'remove'; target: ProviderTargetView }
|
|
3890
|
+
| { kind: 'login' | 'logout'; target: ProviderTargetView; authorization: ProviderAuthorizationRow }
|
|
3891
|
+
| undefined
|
|
3892
|
+
>(undefined)
|
|
3867
3893
|
/** The model row whose effort levels the /model stage lists; undefined shows the model list. */
|
|
3868
3894
|
const [effortFor, setEffortFor] = useState<ModelRow | undefined>(undefined)
|
|
3869
3895
|
/** Effective reasoning effort, shown in the /model picker and switch notice. */
|
|
@@ -3913,10 +3939,10 @@ export function App(props: AppProps): ReactElement {
|
|
|
3913
3939
|
}, [modelLabel, effortLabel])
|
|
3914
3940
|
const [directory, setDirectory] = useState<ModelDirectory | undefined>(undefined)
|
|
3915
3941
|
const [modelError, setModelError] = useState<string | undefined>(undefined)
|
|
3916
|
-
const [providerDirectory, setProviderDirectory] = useState<ProviderSettingsDirectory | undefined>(undefined)
|
|
3917
|
-
const [providerError, setProviderError] = useState<string | undefined>(undefined)
|
|
3918
|
-
const [authorizationDirectory, setAuthorizationDirectory] = useState<ProviderAuthorizationDirectory | undefined>(undefined)
|
|
3919
|
-
const [authorizationError, setAuthorizationError] = useState<string | undefined>(undefined)
|
|
3942
|
+
const [providerDirectory, setProviderDirectory] = useState<ProviderSettingsDirectory | undefined>(undefined)
|
|
3943
|
+
const [providerError, setProviderError] = useState<string | undefined>(undefined)
|
|
3944
|
+
const [authorizationDirectory, setAuthorizationDirectory] = useState<ProviderAuthorizationDirectory | undefined>(undefined)
|
|
3945
|
+
const [authorizationError, setAuthorizationError] = useState<string | undefined>(undefined)
|
|
3920
3946
|
const [modelLoadEpoch, setModelLoadEpoch] = useState(0)
|
|
3921
3947
|
const [notice, setNotice] = useState<{ text: string; tone: NoticeTone } | undefined>(undefined)
|
|
3922
3948
|
const notify = useCallback((text: string, tone: NoticeTone = 'info'): void => {
|
|
@@ -3956,21 +3982,21 @@ export function App(props: AppProps): ReactElement {
|
|
|
3956
3982
|
return () => {
|
|
3957
3983
|
cancelled = true
|
|
3958
3984
|
}
|
|
3959
|
-
}, [modelOpen, modelLoadEpoch, props.loadModelProviders])
|
|
3960
|
-
useEffect(() => {
|
|
3961
|
-
if (!modelOpen || props.loadProviderAuthorizations === undefined) return
|
|
3962
|
-
let cancelled = false
|
|
3963
|
-
setAuthorizationDirectory(undefined)
|
|
3964
|
-
setAuthorizationError(undefined)
|
|
3965
|
-
Promise.resolve().then(() => props.loadProviderAuthorizations!()).then((loaded) => {
|
|
3966
|
-
if (!cancelled) setAuthorizationDirectory(loaded)
|
|
3967
|
-
}, (error: unknown) => {
|
|
3968
|
-
if (!cancelled) setAuthorizationError(error instanceof Error ? error.message : String(error))
|
|
3969
|
-
})
|
|
3970
|
-
return () => {
|
|
3971
|
-
cancelled = true
|
|
3972
|
-
}
|
|
3973
|
-
}, [modelOpen, modelLoadEpoch, props.loadProviderAuthorizations])
|
|
3985
|
+
}, [modelOpen, modelLoadEpoch, props.loadModelProviders])
|
|
3986
|
+
useEffect(() => {
|
|
3987
|
+
if (!modelOpen || props.loadProviderAuthorizations === undefined) return
|
|
3988
|
+
let cancelled = false
|
|
3989
|
+
setAuthorizationDirectory(undefined)
|
|
3990
|
+
setAuthorizationError(undefined)
|
|
3991
|
+
Promise.resolve().then(() => props.loadProviderAuthorizations!()).then((loaded) => {
|
|
3992
|
+
if (!cancelled) setAuthorizationDirectory(loaded)
|
|
3993
|
+
}, (error: unknown) => {
|
|
3994
|
+
if (!cancelled) setAuthorizationError(error instanceof Error ? error.message : String(error))
|
|
3995
|
+
})
|
|
3996
|
+
return () => {
|
|
3997
|
+
cancelled = true
|
|
3998
|
+
}
|
|
3999
|
+
}, [modelOpen, modelLoadEpoch, props.loadProviderAuthorizations])
|
|
3974
4000
|
useEffect(() => {
|
|
3975
4001
|
const subscribe = props.subscribeModelProviders
|
|
3976
4002
|
if (!modelOpen || subscribe === undefined) return
|
|
@@ -3979,19 +4005,21 @@ export function App(props: AppProps): ReactElement {
|
|
|
3979
4005
|
} catch (error: unknown) {
|
|
3980
4006
|
setProviderError(error instanceof Error ? error.message : String(error))
|
|
3981
4007
|
}
|
|
3982
|
-
}, [modelOpen, props.subscribeModelProviders])
|
|
3983
|
-
useEffect(() => {
|
|
3984
|
-
const subscribe = props.subscribeProviderAuthorizations
|
|
3985
|
-
if (!modelOpen || subscribe === undefined) return
|
|
3986
|
-
try {
|
|
3987
|
-
return subscribe(() => setModelLoadEpoch(epoch => epoch + 1))
|
|
3988
|
-
} catch (error: unknown) {
|
|
3989
|
-
setAuthorizationError(error instanceof Error ? error.message : String(error))
|
|
3990
|
-
}
|
|
3991
|
-
}, [modelOpen, props.subscribeProviderAuthorizations])
|
|
4008
|
+
}, [modelOpen, props.subscribeModelProviders])
|
|
4009
|
+
useEffect(() => {
|
|
4010
|
+
const subscribe = props.subscribeProviderAuthorizations
|
|
4011
|
+
if (!modelOpen || subscribe === undefined) return
|
|
4012
|
+
try {
|
|
4013
|
+
return subscribe(() => setModelLoadEpoch(epoch => epoch + 1))
|
|
4014
|
+
} catch (error: unknown) {
|
|
4015
|
+
setAuthorizationError(error instanceof Error ? error.message : String(error))
|
|
4016
|
+
}
|
|
4017
|
+
}, [modelOpen, props.subscribeProviderAuthorizations])
|
|
3992
4018
|
|
|
3993
4019
|
const busy = view.busy
|
|
3994
4020
|
const [showReasoning, setShowReasoning] = useState(false)
|
|
4021
|
+
// Dedupe for the dynamic-budget tripwire: one warning per distinct shape.
|
|
4022
|
+
const budgetWarnRef = useRef<string | undefined>(undefined)
|
|
3995
4023
|
const [verboseOpen, setVerboseOpen] = useState(false)
|
|
3996
4024
|
const [diffView, setDiffView] = useState<GitDiffView | undefined>(undefined)
|
|
3997
4025
|
const [helpOpen, setHelpOpen] = useState(false)
|
|
@@ -4224,6 +4252,22 @@ export function App(props: AppProps): ReactElement {
|
|
|
4224
4252
|
? Math.max(1, Math.floor(streamRows / 3))
|
|
4225
4253
|
: 1
|
|
4226
4254
|
const answerRows = view.streaming === '' ? 0 : Math.max(1, streamRows - reasoningRows)
|
|
4255
|
+
// Dynamic-height tripwire: the allocation must fit dynamicRows by
|
|
4256
|
+
// construction; a future edit that breaks the derivation clamps here
|
|
4257
|
+
// (answer, then reasoning, then settled live rows) and warns once.
|
|
4258
|
+
const liveAudit = clampLiveAllocation(
|
|
4259
|
+
{ live: visibleLiveLines.length, reasoning: reasoningRows, answer: answerRows },
|
|
4260
|
+
dynamicRows,
|
|
4261
|
+
)
|
|
4262
|
+
if (liveAudit.warning !== undefined && budgetWarnRef.current !== liveAudit.warning) {
|
|
4263
|
+
budgetWarnRef.current = liveAudit.warning
|
|
4264
|
+
console.warn(`[dsh-code] ${liveAudit.warning}`)
|
|
4265
|
+
}
|
|
4266
|
+
const auditedLiveLines = liveAudit.allocation.live === visibleLiveLines.length
|
|
4267
|
+
? visibleLiveLines
|
|
4268
|
+
: visibleLiveLines.slice(-liveAudit.allocation.live)
|
|
4269
|
+
const auditedReasoningRows = liveAudit.allocation.reasoning
|
|
4270
|
+
const auditedAnswerRows = liveAudit.allocation.answer
|
|
4227
4271
|
const transcriptVisible = !modelOpen && !helpOpen && !modeOpen && !permissionOpen && !resumeOpen && !pluginOpen && !jobsOpen && !statuslineOpen && !themeOpen && !historyOpen && !agentsOpen && !subagentOpen && !todosOpen && !verboseOpen && diffView === undefined && !approvalPending && !questionPending
|
|
4228
4272
|
const inspectorVisible = verboseOpen && !approvalPending && !questionPending
|
|
4229
4273
|
const modalVisible = modelOpen || helpOpen || modeOpen || permissionOpen || resumeOpen || pluginOpen || jobsOpen || statuslineOpen || themeOpen || historyOpen || agentsOpen || subagentOpen || todosOpen || inspectorVisible || diffView !== undefined || approvalPending || questionPending
|
|
@@ -4248,7 +4292,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
4248
4292
|
appStdout.write(SYNCHRONIZED_UPDATE_END)
|
|
4249
4293
|
}, [appStdout, refreshEpoch])
|
|
4250
4294
|
// An idle Ctrl+R fold toggle joins resize and explicit Ctrl+L as a deliberate
|
|
4251
|
-
// source-backed rebuild of native scrollback
|
|
4295
|
+
// source-backed rebuild of native scrollback.
|
|
4252
4296
|
|
|
4253
4297
|
// Rendered-history cap: when the settled window overflows the trim
|
|
4254
4298
|
// hysteresis, one source-backed replay re-windows it (the rebuild branch
|
|
@@ -4261,21 +4305,21 @@ export function App(props: AppProps): ReactElement {
|
|
|
4261
4305
|
refreshScreen()
|
|
4262
4306
|
}, [settledNeedsTrim, busy, streamingActive])
|
|
4263
4307
|
|
|
4264
|
-
const sessionHasImages = useMemo(() => view.entries.some(entry =>
|
|
4265
|
-
(entry.kind === 'user' || entry.kind === 'pending') && (entry.images?.length ?? 0) > 0), [view.entries])
|
|
4266
|
-
|
|
4267
|
-
/** Apply one /model pick: record the selection, close the panel, report via notice. */
|
|
4268
|
-
const applyModel = (row: ModelRow, effortId: string | undefined): void => {
|
|
4269
|
-
try {
|
|
4270
|
-
const label = props.selectModel(row, effortId)
|
|
4271
|
-
setModelLabel(label)
|
|
4272
|
-
setEffortLabel(effortId)
|
|
4273
|
-
const selected = `${label}${effortId === undefined || effortId === '' ? '' : `@${effortId}`}`
|
|
4274
|
-
if (sessionHasImages && row.inputModalities !== undefined && !row.inputModalities.includes('image')) {
|
|
4275
|
-
notify(`model → ${selected} · image history will be sent as text placeholders`, 'warning')
|
|
4276
|
-
} else {
|
|
4277
|
-
notify(`model → next step uses ${selected}`)
|
|
4278
|
-
}
|
|
4308
|
+
const sessionHasImages = useMemo(() => view.entries.some(entry =>
|
|
4309
|
+
(entry.kind === 'user' || entry.kind === 'pending') && (entry.images?.length ?? 0) > 0), [view.entries])
|
|
4310
|
+
|
|
4311
|
+
/** Apply one /model pick: record the selection, close the panel, report via notice. */
|
|
4312
|
+
const applyModel = (row: ModelRow, effortId: string | undefined): void => {
|
|
4313
|
+
try {
|
|
4314
|
+
const label = props.selectModel(row, effortId)
|
|
4315
|
+
setModelLabel(label)
|
|
4316
|
+
setEffortLabel(effortId)
|
|
4317
|
+
const selected = `${label}${effortId === undefined || effortId === '' ? '' : `@${effortId}`}`
|
|
4318
|
+
if (sessionHasImages && row.inputModalities !== undefined && !row.inputModalities.includes('image')) {
|
|
4319
|
+
notify(`model → ${selected} · image history will be sent as text placeholders`, 'warning')
|
|
4320
|
+
} else {
|
|
4321
|
+
notify(`model → next step uses ${selected}`)
|
|
4322
|
+
}
|
|
4279
4323
|
setModelOpen(false)
|
|
4280
4324
|
setProviderOpen(false)
|
|
4281
4325
|
setProviderAction(undefined)
|
|
@@ -4295,44 +4339,44 @@ export function App(props: AppProps): ReactElement {
|
|
|
4295
4339
|
setEffortFor(undefined)
|
|
4296
4340
|
}
|
|
4297
4341
|
let modelSurface: ReactElement | undefined
|
|
4298
|
-
if (modelOpen && !approvalPending && !questionPending) {
|
|
4299
|
-
if (providerAction?.kind === 'login'
|
|
4300
|
-
&& props.beginProviderAuthorization !== undefined
|
|
4301
|
-
&& props.cancelProviderAuthorization !== undefined
|
|
4302
|
-
&& props.openAuthorizationUrl !== undefined
|
|
4303
|
-
&& props.copyTextValue !== undefined) {
|
|
4304
|
-
modelSurface = createElement(ProviderAuthorizationPanel, {
|
|
4305
|
-
row: providerAction.authorization,
|
|
4306
|
-
begin: props.beginProviderAuthorization,
|
|
4307
|
-
cancel: () => props.cancelProviderAuthorization!(providerAction.authorization),
|
|
4308
|
-
openUrl: props.openAuthorizationUrl,
|
|
4309
|
-
copy: props.copyTextValue,
|
|
4310
|
-
done: () => {
|
|
4311
|
-
const authorization = providerAction.authorization
|
|
4312
|
-
setProviderAction(undefined)
|
|
4313
|
-
setProviderOpen(false)
|
|
4314
|
-
reloadModelSurfaces()
|
|
4315
|
-
notify(`logged in to ${authorization.label}; select a model`)
|
|
4316
|
-
},
|
|
4317
|
-
back: () => {
|
|
4318
|
-
setProviderAction(undefined)
|
|
4319
|
-
setProviderOpen(true)
|
|
4320
|
-
},
|
|
4321
|
-
})
|
|
4322
|
-
} else if (providerAction?.kind === 'logout' && props.logoutProviderAuthorization !== undefined) {
|
|
4323
|
-
modelSurface = createElement(ProviderAuthorizationLogoutPanel, {
|
|
4324
|
-
row: providerAction.authorization,
|
|
4325
|
-
confirm: props.logoutProviderAuthorization,
|
|
4326
|
-
done: () => {
|
|
4327
|
-
const authorization = providerAction.authorization
|
|
4328
|
-
setProviderAction(undefined)
|
|
4329
|
-
setProviderOpen(true)
|
|
4330
|
-
reloadModelSurfaces()
|
|
4331
|
-
notify(`logged out from ${authorization.label}`)
|
|
4332
|
-
},
|
|
4333
|
-
back: () => setProviderAction(undefined),
|
|
4334
|
-
})
|
|
4335
|
-
} else if (providerAction?.kind === 'configure' && props.saveModelProviderConfiguration !== undefined) {
|
|
4342
|
+
if (modelOpen && !approvalPending && !questionPending) {
|
|
4343
|
+
if (providerAction?.kind === 'login'
|
|
4344
|
+
&& props.beginProviderAuthorization !== undefined
|
|
4345
|
+
&& props.cancelProviderAuthorization !== undefined
|
|
4346
|
+
&& props.openAuthorizationUrl !== undefined
|
|
4347
|
+
&& props.copyTextValue !== undefined) {
|
|
4348
|
+
modelSurface = createElement(ProviderAuthorizationPanel, {
|
|
4349
|
+
row: providerAction.authorization,
|
|
4350
|
+
begin: props.beginProviderAuthorization,
|
|
4351
|
+
cancel: () => props.cancelProviderAuthorization!(providerAction.authorization),
|
|
4352
|
+
openUrl: props.openAuthorizationUrl,
|
|
4353
|
+
copy: props.copyTextValue,
|
|
4354
|
+
done: () => {
|
|
4355
|
+
const authorization = providerAction.authorization
|
|
4356
|
+
setProviderAction(undefined)
|
|
4357
|
+
setProviderOpen(false)
|
|
4358
|
+
reloadModelSurfaces()
|
|
4359
|
+
notify(`logged in to ${authorization.label}; select a model`)
|
|
4360
|
+
},
|
|
4361
|
+
back: () => {
|
|
4362
|
+
setProviderAction(undefined)
|
|
4363
|
+
setProviderOpen(true)
|
|
4364
|
+
},
|
|
4365
|
+
})
|
|
4366
|
+
} else if (providerAction?.kind === 'logout' && props.logoutProviderAuthorization !== undefined) {
|
|
4367
|
+
modelSurface = createElement(ProviderAuthorizationLogoutPanel, {
|
|
4368
|
+
row: providerAction.authorization,
|
|
4369
|
+
confirm: props.logoutProviderAuthorization,
|
|
4370
|
+
done: () => {
|
|
4371
|
+
const authorization = providerAction.authorization
|
|
4372
|
+
setProviderAction(undefined)
|
|
4373
|
+
setProviderOpen(true)
|
|
4374
|
+
reloadModelSurfaces()
|
|
4375
|
+
notify(`logged out from ${authorization.label}`)
|
|
4376
|
+
},
|
|
4377
|
+
back: () => setProviderAction(undefined),
|
|
4378
|
+
})
|
|
4379
|
+
} else if (providerAction?.kind === 'configure' && props.saveModelProviderConfiguration !== undefined) {
|
|
4336
4380
|
modelSurface = createElement(ProviderConfigurationPanel, {
|
|
4337
4381
|
target: providerAction.target,
|
|
4338
4382
|
catalog: directory?.rows ?? [],
|
|
@@ -4388,11 +4432,11 @@ export function App(props: AppProps): ReactElement {
|
|
|
4388
4432
|
back: () => setProviderAction(undefined),
|
|
4389
4433
|
})
|
|
4390
4434
|
} else if (providerOpen) {
|
|
4391
|
-
modelSurface = createElement(ProviderPanel, {
|
|
4392
|
-
directory: providerDirectory,
|
|
4393
|
-
error: providerError,
|
|
4394
|
-
authorizations: authorizationDirectory,
|
|
4395
|
-
authorizationError,
|
|
4435
|
+
modelSurface = createElement(ProviderPanel, {
|
|
4436
|
+
directory: providerDirectory,
|
|
4437
|
+
error: providerError,
|
|
4438
|
+
authorizations: authorizationDirectory,
|
|
4439
|
+
authorizationError,
|
|
4396
4440
|
onCredential: (target: ProviderTargetView) => {
|
|
4397
4441
|
if (props.saveModelProviderCredential === undefined) {
|
|
4398
4442
|
notify('API key storage is unavailable in this profile', 'warning')
|
|
@@ -4414,34 +4458,34 @@ export function App(props: AppProps): ReactElement {
|
|
|
4414
4458
|
}
|
|
4415
4459
|
setProviderAction({ kind: 'unset', target })
|
|
4416
4460
|
},
|
|
4417
|
-
onRemove: (target: ProviderTargetView) => {
|
|
4461
|
+
onRemove: (target: ProviderTargetView) => {
|
|
4418
4462
|
if (props.removeModelProvider === undefined) {
|
|
4419
4463
|
notify('provider removal is unavailable in this profile', 'warning')
|
|
4420
4464
|
return
|
|
4421
4465
|
}
|
|
4422
|
-
setProviderAction({ kind: 'remove', target })
|
|
4423
|
-
},
|
|
4424
|
-
onLogin: (target: ProviderTargetView, authorization: ProviderAuthorizationRow) => {
|
|
4425
|
-
if (busy) {
|
|
4426
|
-
notify('provider login is available only while the agent is idle', 'warning')
|
|
4427
|
-
return
|
|
4428
|
-
}
|
|
4429
|
-
if (props.beginProviderAuthorization === undefined
|
|
4430
|
-
|| props.cancelProviderAuthorization === undefined
|
|
4431
|
-
|| props.openAuthorizationUrl === undefined
|
|
4432
|
-
|| props.copyTextValue === undefined) {
|
|
4433
|
-
notify('provider login is unavailable in this profile', 'warning')
|
|
4434
|
-
return
|
|
4435
|
-
}
|
|
4436
|
-
setProviderAction({ kind: 'login', target, authorization })
|
|
4437
|
-
},
|
|
4438
|
-
onLogout: (target: ProviderTargetView, authorization: ProviderAuthorizationRow) => {
|
|
4439
|
-
if (props.logoutProviderAuthorization === undefined) {
|
|
4440
|
-
notify('provider logout is unavailable in this profile', 'warning')
|
|
4441
|
-
return
|
|
4442
|
-
}
|
|
4443
|
-
setProviderAction({ kind: 'logout', target, authorization })
|
|
4444
|
-
},
|
|
4466
|
+
setProviderAction({ kind: 'remove', target })
|
|
4467
|
+
},
|
|
4468
|
+
onLogin: (target: ProviderTargetView, authorization: ProviderAuthorizationRow) => {
|
|
4469
|
+
if (busy) {
|
|
4470
|
+
notify('provider login is available only while the agent is idle', 'warning')
|
|
4471
|
+
return
|
|
4472
|
+
}
|
|
4473
|
+
if (props.beginProviderAuthorization === undefined
|
|
4474
|
+
|| props.cancelProviderAuthorization === undefined
|
|
4475
|
+
|| props.openAuthorizationUrl === undefined
|
|
4476
|
+
|| props.copyTextValue === undefined) {
|
|
4477
|
+
notify('provider login is unavailable in this profile', 'warning')
|
|
4478
|
+
return
|
|
4479
|
+
}
|
|
4480
|
+
setProviderAction({ kind: 'login', target, authorization })
|
|
4481
|
+
},
|
|
4482
|
+
onLogout: (target: ProviderTargetView, authorization: ProviderAuthorizationRow) => {
|
|
4483
|
+
if (props.logoutProviderAuthorization === undefined) {
|
|
4484
|
+
notify('provider logout is unavailable in this profile', 'warning')
|
|
4485
|
+
return
|
|
4486
|
+
}
|
|
4487
|
+
setProviderAction({ kind: 'logout', target, authorization })
|
|
4488
|
+
},
|
|
4445
4489
|
onRetry: reloadModelSurfaces,
|
|
4446
4490
|
onBack: () => setProviderOpen(false),
|
|
4447
4491
|
})
|
|
@@ -4494,22 +4538,39 @@ export function App(props: AppProps): ReactElement {
|
|
|
4494
4538
|
// prefix, so streaming text lands exactly where the composer's input
|
|
4495
4539
|
// text and the settled reply both render (Codex LIVE_PREFIX).
|
|
4496
4540
|
{ flexDirection: 'column' },
|
|
4497
|
-
|
|
4498
|
-
view.streamingReasoning !== '' &&
|
|
4499
|
-
?
|
|
4500
|
-
|
|
4501
|
-
|
|
4502
|
-
|
|
4503
|
-
|
|
4504
|
-
|
|
4505
|
-
|
|
4541
|
+
auditedLiveLines.length === 0 ? undefined : createElement(StyledRows, { lines: auditedLiveLines }),
|
|
4542
|
+
view.streamingReasoning !== '' && auditedReasoningRows > 0
|
|
4543
|
+
? showReasoning
|
|
4544
|
+
? createElement(StreamTail, {
|
|
4545
|
+
text: view.streamingReasoning,
|
|
4546
|
+
prefix: '✻ ',
|
|
4547
|
+
continuationPrefix: ' ',
|
|
4548
|
+
dim: true,
|
|
4549
|
+
maxRows: auditedReasoningRows,
|
|
4550
|
+
})
|
|
4551
|
+
// The collapsed marker shimmers only while reasoning streams
|
|
4552
|
+
// alone: once answer text flows, a periodically re-rendered
|
|
4553
|
+
// animation component would race the store's frame-throttled
|
|
4554
|
+
// notifications and could defer the answer paint by tens to
|
|
4555
|
+
// hundreds of milliseconds (stream-burst contract), so the
|
|
4556
|
+
// marker falls back to the static dim row — same as Deep diving
|
|
4557
|
+
// always yields the live region to streaming content.
|
|
4558
|
+
: view.streaming === ''
|
|
4559
|
+
? createElement(ShimmerLine, { text: '✻ Thinking… (Ctrl/Alt+R to expand)' })
|
|
4560
|
+
: createElement(StreamTail, {
|
|
4561
|
+
text: 'Thinking… (Ctrl/Alt+R to expand)',
|
|
4562
|
+
prefix: '✻ ',
|
|
4563
|
+
continuationPrefix: ' ',
|
|
4564
|
+
dim: true,
|
|
4565
|
+
maxRows: auditedReasoningRows,
|
|
4566
|
+
})
|
|
4506
4567
|
: undefined,
|
|
4507
|
-
view.streaming !== '' &&
|
|
4568
|
+
view.streaming !== '' && auditedAnswerRows > 0
|
|
4508
4569
|
? createElement(
|
|
4509
4570
|
StreamTail,
|
|
4510
4571
|
// The same two-column gutter as settled replies: streamed text
|
|
4511
4572
|
// lands exactly where the assembled message will render.
|
|
4512
|
-
{ text: view.streaming, dim: false, maxRows:
|
|
4573
|
+
{ text: view.streaming, dim: false, maxRows: auditedAnswerRows, prefix: ' ' },
|
|
4513
4574
|
busy ? createElement(Caret) : undefined,
|
|
4514
4575
|
)
|
|
4515
4576
|
: undefined,
|
|
@@ -4517,7 +4578,7 @@ export function App(props: AppProps): ReactElement {
|
|
|
4517
4578
|
)
|
|
4518
4579
|
: undefined,
|
|
4519
4580
|
transcriptVisible ? createElement(TodoPanel, { todos: view.todos }) : undefined,
|
|
4520
|
-
transcriptVisible ? createElement(AgentsLine, { rows: agentRows }) : undefined,
|
|
4581
|
+
transcriptVisible ? createElement(AgentsLine, { rows: agentRows, total: props.subagents.getTotalSeen() }) : undefined,
|
|
4521
4582
|
todosOpen && !approvalPending && !questionPending
|
|
4522
4583
|
? createElement(MemoTodoListPanel, {
|
|
4523
4584
|
todos: view.todos,
|
|
@@ -4686,16 +4747,17 @@ export function App(props: AppProps): ReactElement {
|
|
|
4686
4747
|
descriptors,
|
|
4687
4748
|
skills,
|
|
4688
4749
|
dispatch: props.dispatch,
|
|
4750
|
+
applyEditorKeys: props.applyEditorKeys,
|
|
4689
4751
|
steer: props.steer,
|
|
4690
4752
|
interrupt: props.interrupt,
|
|
4691
4753
|
quit: props.quit,
|
|
4692
4754
|
openModel: () => {
|
|
4693
4755
|
setDirectory(undefined)
|
|
4694
4756
|
setModelError(undefined)
|
|
4695
|
-
setProviderDirectory(undefined)
|
|
4696
|
-
setProviderError(undefined)
|
|
4697
|
-
setAuthorizationDirectory(undefined)
|
|
4698
|
-
setAuthorizationError(undefined)
|
|
4757
|
+
setProviderDirectory(undefined)
|
|
4758
|
+
setProviderError(undefined)
|
|
4759
|
+
setAuthorizationDirectory(undefined)
|
|
4760
|
+
setAuthorizationError(undefined)
|
|
4699
4761
|
setProviderOpen(false)
|
|
4700
4762
|
setProviderAction(undefined)
|
|
4701
4763
|
setEffortFor(undefined)
|
|
@@ -4785,19 +4847,20 @@ export function App(props: AppProps): ReactElement {
|
|
|
4785
4847
|
props.store.reset()
|
|
4786
4848
|
},
|
|
4787
4849
|
refresh: refreshScreen,
|
|
4788
|
-
// Ctrl+R flips the reasoning fold.
|
|
4789
|
-
//
|
|
4790
|
-
//
|
|
4791
|
-
//
|
|
4792
|
-
//
|
|
4793
|
-
//
|
|
4850
|
+
// Ctrl+R flips the reasoning fold. Rows already emitted through
|
|
4851
|
+
// Static are native scrollback, so the fold state of past entries can
|
|
4852
|
+
// only change through the source-backed replay (one clear + rebuild,
|
|
4853
|
+
// wrapped in a synchronized frame). Every toggle replays globally and
|
|
4854
|
+
// immediately — including mid-turn — so the whole transcript stays at
|
|
4855
|
+
// one fold state; the resize path already proves replaying during a
|
|
4856
|
+
// stream is safe.
|
|
4794
4857
|
toggleReasoning: () => {
|
|
4795
4858
|
setShowReasoning(current => !current)
|
|
4796
|
-
|
|
4859
|
+
refreshScreen()
|
|
4797
4860
|
},
|
|
4798
|
-
loadMentions: props.loadMentions,
|
|
4799
|
-
inspectImages: props.inspectImages,
|
|
4800
|
-
prepareImages: props.prepareImages,
|
|
4861
|
+
loadMentions: props.loadMentions,
|
|
4862
|
+
inspectImages: props.inspectImages,
|
|
4863
|
+
prepareImages: props.prepareImages,
|
|
4801
4864
|
cyclePermission: props.cyclePermission,
|
|
4802
4865
|
exportTranscript: props.exportTranscript,
|
|
4803
4866
|
renameTitle: props.renameTitle,
|