@workerdeck/ui 2.7.2 → 2.9.0
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.md +30 -30
- package/build/{SessionPanel-B5FfiBxE.mjs → SessionPanel-BClL7zUS.mjs} +694 -413
- package/build/SessionPanel-BClL7zUS.mjs.map +1 -0
- package/build/{SessionPanel-_lQLhWOw.d.mts → SessionPanel-BVbau2eR.d.mts} +68 -4
- package/build/format.mjs +1 -1
- package/build/index.d.mts +22 -15
- package/build/index.mjs +6 -6
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +26 -2
- package/build/{status-BO9wJloK.mjs → status-kmsBhuFG.mjs} +3 -3
- package/build/status-kmsBhuFG.mjs.map +1 -0
- package/build/workspace.d.mts +3 -2
- package/build/workspace.mjs +7 -8
- package/build/workspace.mjs.map +1 -1
- package/package.json +18 -18
- package/src/components/agent/Composer.tsx +86 -84
- package/src/components/agent/ContextDialog.tsx +1 -1
- package/src/components/agent/EntryIcon.tsx +1 -1
- package/src/components/agent/FileTree.tsx +1 -1
- package/src/components/agent/FileViewer.tsx +1 -1
- package/src/components/agent/HostFilesDialog.tsx +2 -2
- package/src/components/agent/PermissionModeSelect.tsx +2 -2
- package/src/components/agent/PermissionPrompt.tsx +2 -2
- package/src/components/agent/SessionBrowser.tsx +1 -1
- package/src/components/agent/SessionItem.tsx +4 -2
- package/src/components/agent/SessionPanel.tsx +306 -226
- package/src/components/agent/SessionWorkspace.tsx +3 -0
- package/src/components/agent/SkillsDialog.tsx +3 -3
- package/src/components/agent/StatusBar.tsx +1 -1
- package/src/components/agent/TaskList.tsx +2 -2
- package/src/components/agent/TranscriptRows.tsx +1 -1
- package/src/components/agent/UsageDialog.tsx +1 -1
- package/src/components/agent/UsageMeters.tsx +17 -4
- package/src/components/agent/composer-commands.ts +142 -0
- package/src/components/agent/use-path-links.ts +2 -7
- package/src/components/agent/use-subagent-frame.ts +2 -2
- package/src/components/agent/use-transcript-jumps.ts +2 -2
- package/src/components/prompt-area/clipboard-helpers.ts +2 -2
- package/src/components/prompt-area/cursor-helpers.ts +7 -7
- package/src/components/prompt-area/dom-helpers.ts +8 -8
- package/src/components/prompt-area/html-to-markdown.ts +2 -2
- package/src/components/prompt-area/prompt-area-engine.ts +4 -4
- package/src/components/prompt-area/prompt-area-list-ops.ts +10 -10
- package/src/components/prompt-area/prompt-area.tsx +2 -2
- package/src/components/prompt-area/trigger-presets.ts +6 -6
- package/src/components/prompt-area/types.ts +6 -6
- package/src/components/prompt-area/use-chip-editing.ts +8 -8
- package/src/components/prompt-area/use-markdown-mode.ts +3 -3
- package/src/components/prompt-area/use-prompt-area-events.ts +3 -3
- package/src/components/prompt-area/use-prompt-area-keydown.ts +3 -3
- package/src/components/prompt-area/use-prompt-area-state.ts +4 -4
- package/src/components/prompt-area/use-prompt-area.ts +5 -5
- package/src/components/prompt-area/use-trigger-search.ts +1 -1
- package/src/components/terminal/PermissionPrompt.tsx +4 -4
- package/src/components/terminal/affordances.tsx +1 -1
- package/src/components/terminal/file-link.tsx +20 -0
- package/src/components/terminal/height.ts +2 -2
- package/src/components/terminal/items.tsx +2 -2
- package/src/components/terminal/markdown.tsx +31 -5
- package/src/components/terminal/scrubber.tsx +1 -1
- package/src/components/terminal/todos.ts +1 -1
- package/src/components/ui/Splitter.tsx +1 -1
- package/src/index.ts +11 -1
- package/src/lib/context-note.ts +3 -3
- package/src/lib/file-link.ts +70 -0
- package/src/lib/format.ts +2 -2
- package/src/styles/scoped.entry.css +2 -2
- package/src/styles/terminal.css +94 -79
- package/src/styles/theme.css +39 -39
- package/build/SessionPanel-B5FfiBxE.mjs.map +0 -1
- package/build/status-BO9wJloK.mjs.map +0 -1
|
@@ -18,12 +18,12 @@
|
|
|
18
18
|
|
|
19
19
|
import type { TriggerConfig, TriggerPosition } from './types.ts'
|
|
20
20
|
|
|
21
|
-
// Shared option type
|
|
21
|
+
// Shared option type - everything in TriggerConfig except the keys each
|
|
22
22
|
// factory sets by default.
|
|
23
23
|
|
|
24
24
|
type TriggerPresetOptions = Omit<Partial<TriggerConfig>, 'char' | 'position' | 'mode'>
|
|
25
25
|
|
|
26
|
-
// @mention
|
|
26
|
+
// @mention - dropdown at any position
|
|
27
27
|
|
|
28
28
|
export type MentionTriggerOptions = TriggerPresetOptions & {
|
|
29
29
|
/** Override the trigger character. Defaults to `'@'`. */
|
|
@@ -48,14 +48,14 @@ export function mentionTrigger(opts: MentionTriggerOptions = {}): TriggerConfig
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
// /command
|
|
51
|
+
// /command - dropdown anywhere (opt into line-start-only with `position`)
|
|
52
52
|
|
|
53
53
|
export type CommandTriggerOptions = TriggerPresetOptions & {
|
|
54
54
|
/** Override the trigger character. Defaults to `'/'`. */
|
|
55
55
|
char?: string
|
|
56
56
|
/**
|
|
57
57
|
* Where the command trigger is valid. Defaults to `'any'`, so commands fire
|
|
58
|
-
* anywhere a `/` follows whitespace
|
|
58
|
+
* anywhere a `/` follows whitespace - not just at the start of a line.
|
|
59
59
|
* Set to `'start'` to restrict the dropdown to the very start of the input
|
|
60
60
|
* or immediately after a newline (the classic slash-command behavior).
|
|
61
61
|
*/
|
|
@@ -83,7 +83,7 @@ export function commandTrigger(opts: CommandTriggerOptions = {}): TriggerConfig
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
// #hashtag
|
|
86
|
+
// #hashtag - dropdown at any position, auto-resolve on space
|
|
87
87
|
|
|
88
88
|
export type HashtagTriggerOptions = TriggerPresetOptions & {
|
|
89
89
|
/** Override the trigger character. Defaults to `'#'`. */
|
|
@@ -132,7 +132,7 @@ export function callbackTrigger(opts: CallbackTriggerOptions): TriggerConfig {
|
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
// Launch trigger
|
|
135
|
+
// Launch trigger - fires onActivate and swallows the character
|
|
136
136
|
|
|
137
137
|
export type LaunchTriggerOptions = Omit<Partial<TriggerConfig>, 'mode'> & {
|
|
138
138
|
/** The trigger character. Required. */
|
|
@@ -46,7 +46,7 @@ export type TriggerPosition = 'start' | 'any'
|
|
|
46
46
|
* - 'dropdown': Shows a popover with suggestions from `onSearch`
|
|
47
47
|
* - 'callback': Inserts the char, then fires `onActivate` with the typed query
|
|
48
48
|
* - 'launch': Fires `onActivate` on keydown and SUPPRESSES the char (it never
|
|
49
|
-
* enters the editor)
|
|
49
|
+
* enters the editor) - for opening an external surface (dialog, palette) where
|
|
50
50
|
* no in-editor text should appear. Honors `position` like the other modes.
|
|
51
51
|
*/
|
|
52
52
|
export type TriggerMode = 'dropdown' | 'callback' | 'launch'
|
|
@@ -102,7 +102,7 @@ export type TriggerConfig = {
|
|
|
102
102
|
* For 'dropdown' mode: opt a suggestion out of becoming a chip.
|
|
103
103
|
*
|
|
104
104
|
* Return a string and the trigger's range is replaced with that **plain,
|
|
105
|
-
* editable text**
|
|
105
|
+
* editable text** - the trigger character included - with the caret left at
|
|
106
106
|
* its end. Return undefined and the suggestion resolves to a chip as usual,
|
|
107
107
|
* so one dropdown can mix both kinds.
|
|
108
108
|
*
|
|
@@ -239,7 +239,7 @@ export type PromptAreaProps = {
|
|
|
239
239
|
/**
|
|
240
240
|
* When markdown is on, the editor rewrites typed list markers (`- ` / `* `)
|
|
241
241
|
* to a `•` bullet glyph in the model. Set to `false` to keep the original
|
|
242
|
-
* marker in the value/`onChange` text
|
|
242
|
+
* marker in the value/`onChange` text - needed when a host renders the output
|
|
243
243
|
* as real markdown, where `•` is not a valid list marker. Default `true`.
|
|
244
244
|
*/
|
|
245
245
|
normalizeBullets?: boolean
|
|
@@ -271,8 +271,8 @@ export type PromptAreaProps = {
|
|
|
271
271
|
* the caret kept where the edit happened. Chips count as their
|
|
272
272
|
* `trigger + displayText` length.
|
|
273
273
|
*
|
|
274
|
-
* The cap applies to typing only. Paste is not capped
|
|
275
|
-
* `onRawPaste` if needed
|
|
274
|
+
* The cap applies to typing only. Paste is not capped - divert it via
|
|
275
|
+
* `onRawPaste` if needed - and the imperative `setText` / `appendText` also
|
|
276
276
|
* bypass it, so a programmatic write can exceed the cap until the next
|
|
277
277
|
* keystroke truncates.
|
|
278
278
|
*/
|
|
@@ -316,7 +316,7 @@ export type PromptAreaProps = {
|
|
|
316
316
|
onBlur?: (e: React.FocusEvent<HTMLDivElement>) => void
|
|
317
317
|
/**
|
|
318
318
|
* Called at the start of a paste, before PromptArea reads the clipboard. Call
|
|
319
|
-
* `preventDefault()` to take over the paste completely
|
|
319
|
+
* `preventDefault()` to take over the paste completely - e.g. to divert large
|
|
320
320
|
* text or non-image files to an upload pipeline. The built-in segment/image
|
|
321
321
|
* paste handling is skipped when the event's default is prevented.
|
|
322
322
|
*/
|
|
@@ -80,7 +80,7 @@ export function useChipEditing({
|
|
|
80
80
|
|
|
81
81
|
// The chip node currently edited via `reopenOnChipClick`, kept in lockstep with
|
|
82
82
|
// `editingChip`/`activeTrigger`. Answers "is THIS exact element the open one" by
|
|
83
|
-
// reference identity
|
|
83
|
+
// reference identity - trigger+value cannot distinguish two chips sharing a value.
|
|
84
84
|
const openChipNode = useRef<HTMLElement | null>(null)
|
|
85
85
|
|
|
86
86
|
// Set by `handleMouseDown` when the mousedown landed on `openChipNode.current`;
|
|
@@ -88,7 +88,7 @@ export function useChipEditing({
|
|
|
88
88
|
// "toggle closed". A real mousedown on the editor root rather than a
|
|
89
89
|
// `dismissTrigger` flag: bubbling reaches the root before `document`, where
|
|
90
90
|
// TriggerPopover's outside-click dismiss listens and would clear `openChipNode`
|
|
91
|
-
// first
|
|
91
|
+
// first - and it stays scoped to this node, so an unrelated dismiss cannot poison
|
|
92
92
|
// a later click on the same chip.
|
|
93
93
|
const suppressReopenChip = useRef<HTMLElement | null>(null)
|
|
94
94
|
|
|
@@ -106,7 +106,7 @@ export function useChipEditing({
|
|
|
106
106
|
|
|
107
107
|
let node: Node | null = target
|
|
108
108
|
while (node && node !== editor) {
|
|
109
|
-
// Check for URL link click
|
|
109
|
+
// Check for URL link click - only navigate on Cmd/Ctrl+Click;
|
|
110
110
|
// plain click just positions the cursor for editing.
|
|
111
111
|
if (isLinkElement(node)) {
|
|
112
112
|
if (e.metaKey || e.ctrlKey) {
|
|
@@ -137,12 +137,12 @@ export function useChipEditing({
|
|
|
137
137
|
if (chip) {
|
|
138
138
|
// Native chip-click dropdown: reopen this trigger's suggestions
|
|
139
139
|
// anchored to the chip so the selection can replace it in place.
|
|
140
|
-
// Gated on `!disabled`
|
|
140
|
+
// Gated on `!disabled` - a disabled composer must not accept edits
|
|
141
141
|
// through any path, including this one.
|
|
142
142
|
const config = triggers.find((t) => t.char === chip.trigger)
|
|
143
143
|
// A click on THIS exact chip element while its own dropdown was
|
|
144
144
|
// open just closed it (see `suppressReopenChip` and
|
|
145
|
-
// `handleMouseDown`)
|
|
145
|
+
// `handleMouseDown`) - treat that as a toggle-close, not a reopen.
|
|
146
146
|
const wasOpenForThisChip = suppressReopenChip.current === node
|
|
147
147
|
suppressReopenChip.current = null
|
|
148
148
|
if (!disabled && !wasOpenForThisChip && config?.reopenOnChipClick && config.mode === 'dropdown' && config.onSearch) {
|
|
@@ -212,7 +212,7 @@ export function useChipEditing({
|
|
|
212
212
|
}
|
|
213
213
|
const editor = editorRef.current
|
|
214
214
|
if (editor && !disabled) {
|
|
215
|
-
// Re-verify the click-time index still holds the same chip
|
|
215
|
+
// Re-verify the click-time index still holds the same chip - the
|
|
216
216
|
// model may have shifted (external value update, undo/redo) while
|
|
217
217
|
// the dropdown was open. If it moved, recover ONLY when exactly one
|
|
218
218
|
// chip in the document now matches trigger+value: with duplicates,
|
|
@@ -258,7 +258,7 @@ export function useChipEditing({
|
|
|
258
258
|
renderSegmentsToDOM(newSegments)
|
|
259
259
|
|
|
260
260
|
// Same value + display text + data: treat as a no-op confirmation
|
|
261
|
-
// rather than a destructive delete+add
|
|
261
|
+
// rather than a destructive delete+add - onChipDelete is
|
|
262
262
|
// documented as firing on backspace/forward-delete, not on
|
|
263
263
|
// re-confirming the already-selected suggestion.
|
|
264
264
|
const unchanged =
|
|
@@ -271,7 +271,7 @@ export function useChipEditing({
|
|
|
271
271
|
}
|
|
272
272
|
|
|
273
273
|
// +1 when a space was inserted, matching resolveChip's own
|
|
274
|
-
// "+1 accounts for the trailing space after the chip" placement
|
|
274
|
+
// "+1 accounts for the trailing space after the chip" placement -
|
|
275
275
|
// landing exactly at the chip's end would put the caret at the
|
|
276
276
|
// same bare element boundary the inserted space exists to avoid.
|
|
277
277
|
const caretOffset = segmentsToPlainText(newSegments.slice(0, segIdx + 1)).length + (insertedSpace ? 1 : 0)
|
|
@@ -37,7 +37,7 @@ import { useCallback, useMemo, useState } from 'react'
|
|
|
37
37
|
*/
|
|
38
38
|
export type PromptAreaMode = 'markdown' | 'plain'
|
|
39
39
|
|
|
40
|
-
/** Returns the other mode. Pure
|
|
40
|
+
/** Returns the other mode. Pure - handy for building custom toggles. */
|
|
41
41
|
export function oppositeMode(mode: PromptAreaMode): PromptAreaMode {
|
|
42
42
|
return mode === 'markdown' ? 'plain' : 'markdown'
|
|
43
43
|
}
|
|
@@ -47,7 +47,7 @@ export type UseMarkdownModeOptions = {
|
|
|
47
47
|
initialMode?: PromptAreaMode
|
|
48
48
|
/**
|
|
49
49
|
* Controlled mode. When provided, the hook mirrors this value and never owns
|
|
50
|
-
* its own state
|
|
50
|
+
* its own state - drive changes through `onModeChange`.
|
|
51
51
|
*/
|
|
52
52
|
mode?: PromptAreaMode
|
|
53
53
|
/** Called with the next mode whenever `toggle`/`setMode` change it. */
|
|
@@ -57,7 +57,7 @@ export type UseMarkdownModeOptions = {
|
|
|
57
57
|
export type MarkdownModeState = {
|
|
58
58
|
/** The active mode. */
|
|
59
59
|
mode: PromptAreaMode
|
|
60
|
-
/** `true` in markdown mode
|
|
60
|
+
/** `true` in markdown mode - spread onto `<PromptArea markdown={markdown} />`. */
|
|
61
61
|
markdown: boolean
|
|
62
62
|
/** `true` in plain-text mode (the inverse of `markdown`). */
|
|
63
63
|
isPlainText: boolean
|
|
@@ -165,9 +165,9 @@ export function usePromptAreaEvents(deps: EventHandlerDeps): PromptAreaEventHand
|
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
// When markdown mode is on, prefer the richest clipboard flavor:
|
|
168
|
-
// 1. text/markdown
|
|
168
|
+
// 1. text/markdown - some apps (e.g. Slack) hand out markdown directly,
|
|
169
169
|
// preserving nested lists that their text/plain flattens.
|
|
170
|
-
// 2. text/html
|
|
170
|
+
// 2. text/html - convert web/Notion/Docs/GitHub HTML to markdown.
|
|
171
171
|
// Otherwise (markdown off, or neither present) fall back to plain text.
|
|
172
172
|
let text = ''
|
|
173
173
|
if (markdownEnabled) {
|
|
@@ -180,7 +180,7 @@ export function usePromptAreaEvents(deps: EventHandlerDeps): PromptAreaEventHand
|
|
|
180
180
|
} else {
|
|
181
181
|
const html = e.clipboardData.getData('text/html')
|
|
182
182
|
// A converter failure (e.g. stack overflow on pathologically deep
|
|
183
|
-
// nesting) must not drop the paste
|
|
183
|
+
// nesting) must not drop the paste - leave text empty so the
|
|
184
184
|
// text/plain fallback below still runs.
|
|
185
185
|
if (html) {
|
|
186
186
|
try {
|
|
@@ -373,7 +373,7 @@ export function usePromptAreaKeydown({
|
|
|
373
373
|
}
|
|
374
374
|
|
|
375
375
|
// 1.75 Launch triggers: a trigger with mode 'launch' fires onActivate on
|
|
376
|
-
// keydown and suppresses the char so it never enters the editor
|
|
376
|
+
// keydown and suppresses the char so it never enters the editor - for
|
|
377
377
|
// opening an external surface (dialog, palette). The DOM read is gated on
|
|
378
378
|
// the typed key actually matching a launch char, so it stays off the hot
|
|
379
379
|
// path. insertChip still inserts a chip at the cursor if the consumer
|
|
@@ -406,7 +406,7 @@ export function usePromptAreaKeydown({
|
|
|
406
406
|
// 2. Trigger dropdown navigation. Gated on the dropdown actually being
|
|
407
407
|
// ON SCREEN, which matches TriggerPopover's own render condition
|
|
408
408
|
// (non-empty suggestions, OR loading/error/emptyMessage) rather than
|
|
409
|
-
// just `suggestions.length > 0`
|
|
409
|
+
// just `suggestions.length > 0` - otherwise a popover left open in a
|
|
410
410
|
// loading/empty state (e.g. right after a chip-click reopen, before its
|
|
411
411
|
// empty-query search resolves) lets Enter fall through to onSubmit and
|
|
412
412
|
// Escape fall through to onEscape while still visibly on screen.
|
|
@@ -499,7 +499,7 @@ export function usePromptAreaKeydown({
|
|
|
499
499
|
}
|
|
500
500
|
|
|
501
501
|
// 3. Enter without Shift (skipping IME): under `submitOnEnter` it submits
|
|
502
|
-
// *unconditionally*
|
|
502
|
+
// *unconditionally* - the send key must not turn into "another bullet" because
|
|
503
503
|
// of what the line above starts with; list continuation lives on Shift+Enter
|
|
504
504
|
// (branch 2.8). Without `submitOnEnter`, Enter is the newline key and continues.
|
|
505
505
|
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
|
@@ -35,11 +35,11 @@ export type UsePromptAreaStateOptions = {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
export type PromptAreaBind = {
|
|
38
|
-
/** Ref to attach to PromptArea
|
|
38
|
+
/** Ref to attach to PromptArea - gives access to imperative methods. */
|
|
39
39
|
ref: React.RefObject<PromptAreaHandle | null>
|
|
40
|
-
/** Current segment array
|
|
40
|
+
/** Current segment array - pass as `value` prop. */
|
|
41
41
|
value: Segment[]
|
|
42
|
-
/** Setter
|
|
42
|
+
/** Setter - pass as `onChange` prop. */
|
|
43
43
|
onChange: (segments: Segment[]) => void
|
|
44
44
|
}
|
|
45
45
|
|
|
@@ -83,7 +83,7 @@ export function usePromptAreaState(options: UsePromptAreaStateOptions = {}): Pro
|
|
|
83
83
|
|
|
84
84
|
const chips = useMemo(() => value.filter((seg): seg is ChipSegment => seg.type === 'chip'), [value])
|
|
85
85
|
|
|
86
|
-
// Bind object
|
|
86
|
+
// Bind object - safe to spread onto <PromptArea>
|
|
87
87
|
const bind = useMemo<PromptAreaBind>(() => ({ ref, value, onChange: setValue }), [value])
|
|
88
88
|
|
|
89
89
|
const clear = useCallback(() => {
|
|
@@ -88,7 +88,7 @@ type UsePromptAreaReturn = {
|
|
|
88
88
|
const UNDO_DEBOUNCE_MS = 300
|
|
89
89
|
|
|
90
90
|
// Shared default so an omitted `triggers` prop keeps a stable identity across
|
|
91
|
-
// renders
|
|
91
|
+
// renders - a fresh [] would re-create renderSegmentsToDOM (and everything
|
|
92
92
|
// keyed on it) on every parent render.
|
|
93
93
|
const NO_TRIGGERS: TriggerConfig[] = []
|
|
94
94
|
|
|
@@ -401,7 +401,7 @@ export function usePromptArea({
|
|
|
401
401
|
// pending passive effects before rendering that keystroke's onChange. The
|
|
402
402
|
// stale flush would compare an older render's `value` against the
|
|
403
403
|
// lastRenderedValue that handleInput just advanced, read its own echo lag as
|
|
404
|
-
// an external change, and rewrite the editor to pre-keystroke content
|
|
404
|
+
// an external change, and rewrite the editor to pre-keystroke content -
|
|
405
405
|
// leaving the caret behind the character once the echo re-rendered it.
|
|
406
406
|
// Inside the commit, `value` can never be older than lastRenderedValue.
|
|
407
407
|
useLayoutEffect(() => {
|
|
@@ -423,7 +423,7 @@ export function usePromptArea({
|
|
|
423
423
|
}
|
|
424
424
|
|
|
425
425
|
// A rebuild churns the caret and the decorations, so skip it when the
|
|
426
|
-
// editor already shows exactly this content
|
|
426
|
+
// editor already shows exactly this content - e.g. a consumer echoed our
|
|
427
427
|
// onChange back re-chunked, or the DOM is split by decorations. Adopt the
|
|
428
428
|
// value so the equality guard above short-circuits from here on.
|
|
429
429
|
if (segmentsContentEqual(readSegmentsFromDOM(), value)) {
|
|
@@ -520,7 +520,7 @@ export function usePromptArea({
|
|
|
520
520
|
// Native structural edits (e.g. a Backspace that deleted or merged a list
|
|
521
521
|
// row) bypass applyEditResult, so rebuild ordered-list numbering here too.
|
|
522
522
|
// handleInput fires on every keystroke, so gate on a genuine ordered-list
|
|
523
|
-
// run
|
|
523
|
+
// run - this renumbers a real list (1,2,4 → 1,2,3) but leaves incidental
|
|
524
524
|
// numeric prose ("1985. Born / 2020. Died") untouched.
|
|
525
525
|
let nextSegments = segments
|
|
526
526
|
let renumberedCursor: number | null = null
|
|
@@ -643,7 +643,7 @@ export function usePromptArea({
|
|
|
643
643
|
|
|
644
644
|
const selectSuggestion = selectSuggestionInternal
|
|
645
645
|
|
|
646
|
-
// Handle key events
|
|
646
|
+
// Handle key events - the numbered router and its chip-deletion helpers live in
|
|
647
647
|
// use-prompt-area-keydown.ts.
|
|
648
648
|
|
|
649
649
|
const handleKeyDown = usePromptAreaKeydown({
|
|
@@ -25,7 +25,7 @@ export function useTriggerSearch(): UseTriggerSearchReturn {
|
|
|
25
25
|
const [suggestionsLoading, setSuggestionsLoading] = useState(false)
|
|
26
26
|
const [suggestionsError, setSuggestionsError] = useState<string | null>(null)
|
|
27
27
|
|
|
28
|
-
// Version counter
|
|
28
|
+
// Version counter - belt-and-suspenders alongside AbortController
|
|
29
29
|
const searchVersion = useRef(0)
|
|
30
30
|
const abortController = useRef<AbortController | null>(null)
|
|
31
31
|
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
@@ -42,8 +42,8 @@ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className
|
|
|
42
42
|
onCancel={() => setDenying(false)}
|
|
43
43
|
placeholder={
|
|
44
44
|
plan
|
|
45
|
-
? 'What should change? (optional)
|
|
46
|
-
: 'Reason (optional)
|
|
45
|
+
? 'What should change? (optional) - the agent keeps planning and reads this'
|
|
46
|
+
: 'Reason (optional) - the agent reads this and can try something else'
|
|
47
47
|
}
|
|
48
48
|
/>
|
|
49
49
|
) : undefined
|
|
@@ -51,7 +51,7 @@ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className
|
|
|
51
51
|
const options = plan
|
|
52
52
|
? [
|
|
53
53
|
{ key: 'allow', label: 'Approve plan' },
|
|
54
|
-
{ key: 'deny', label: 'Keep planning
|
|
54
|
+
{ key: 'deny', label: 'Keep planning - tell it what to change', detail: reasonInput },
|
|
55
55
|
{ key: 'stop', label: 'No, and stop the turn', danger: true },
|
|
56
56
|
]
|
|
57
57
|
: [
|
|
@@ -116,7 +116,7 @@ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className
|
|
|
116
116
|
<Hint>
|
|
117
117
|
{denying
|
|
118
118
|
? 'Enter to send · Shift+Enter for a new line · Esc to go back'
|
|
119
|
-
: 'Enter to select · ↑/↓ to navigate · 1
|
|
119
|
+
: 'Enter to select · ↑/↓ to navigate · 1-3 to choose · Esc to cancel'}
|
|
120
120
|
</Hint>
|
|
121
121
|
</div>
|
|
122
122
|
)
|
|
@@ -44,7 +44,7 @@ export function WithActions({ actions, children, className }: { actions: ReactNo
|
|
|
44
44
|
|
|
45
45
|
// Bookmarks are a host concern (which items, where they persist); the transcript only needs
|
|
46
46
|
// membership and a toggle. A missing provider renders no action at all, so embeddings that
|
|
47
|
-
// never wire bookmarks pay nothing
|
|
47
|
+
// never wire bookmarks pay nothing - the same contract AffordanceContext has.
|
|
48
48
|
export type BookmarkHandle = {
|
|
49
49
|
has: (itemId: string) => boolean
|
|
50
50
|
toggle: (itemId: string) => void
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
export type FileLinkOpener = (path: string, line?: number) => void
|
|
4
|
+
|
|
5
|
+
export type FileLinkHandle = {
|
|
6
|
+
cwd?: string
|
|
7
|
+
open: FileLinkOpener
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const FileLinkContext = createContext<FileLinkHandle | undefined>(undefined)
|
|
11
|
+
|
|
12
|
+
// A missing provider leaves every link an ordinary anchor, so embeddings that have nowhere to
|
|
13
|
+
// open a file pay nothing - the same contract BookmarkContext has.
|
|
14
|
+
export function FileLinkProvider({ value, children }: { value: FileLinkHandle | undefined; children: ReactNode }) {
|
|
15
|
+
return <FileLinkContext.Provider value={value}>{children}</FileLinkContext.Provider>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function useFileLinks(): FileLinkHandle | undefined {
|
|
19
|
+
return useContext(FileLinkContext)
|
|
20
|
+
}
|
|
@@ -102,7 +102,7 @@ type Token = { kind: 'word' | 'space'; w: number; exact: boolean }
|
|
|
102
102
|
const TAB_SIZE = 2
|
|
103
103
|
|
|
104
104
|
// Break after these unless a digit follows; `?` is here because Chrome breaks long URLs after it (verified against real break rects).
|
|
105
|
-
const BREAK_AFTER = new Set(['-', '
|
|
105
|
+
const BREAK_AFTER = new Set(['-', '-', '-', '?'])
|
|
106
106
|
|
|
107
107
|
const PLAIN_ASCII = /^[\x20-\x7e]*$/
|
|
108
108
|
|
|
@@ -181,7 +181,7 @@ function tokenize(line: string): Token[] {
|
|
|
181
181
|
const { w, exact } = clusterCells(segment)
|
|
182
182
|
const cp = segment.codePointAt(0) ?? 0
|
|
183
183
|
if (isWide(cp) || PICTOGRAPHIC.test(segment)) {
|
|
184
|
-
// Each wide cluster is its own token
|
|
184
|
+
// Each wide cluster is its own token - a break may fall between any two.
|
|
185
185
|
flushWord()
|
|
186
186
|
tokens.push({ kind: 'word', w, exact })
|
|
187
187
|
col += w
|
|
@@ -173,8 +173,8 @@ export function ToolRow({ item }: { item: ToolCallItem }) {
|
|
|
173
173
|
}}
|
|
174
174
|
>
|
|
175
175
|
{truncated
|
|
176
|
-
? `… +${missing.toLocaleString()} chars
|
|
177
|
-
: `… +${hidden} line${hidden === 1 ? '' : 's'}
|
|
176
|
+
? `… +${missing.toLocaleString()} chars - fetch the rest`
|
|
177
|
+
: `… +${hidden} line${hidden === 1 ? '' : 's'} - show all ${text.length.toLocaleString()} chars`}
|
|
178
178
|
</button>
|
|
179
179
|
) : (
|
|
180
180
|
<>
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { memo, type ReactNode } from 'react'
|
|
2
2
|
import { Streamdown, type Components } from 'streamdown'
|
|
3
3
|
import { cn } from '../../lib/utils.ts'
|
|
4
|
+
import { parseFileLink } from '../../lib/file-link.ts'
|
|
4
5
|
import { CopyAction, WithActions } from './affordances.tsx'
|
|
6
|
+
import { useFileLinks } from './file-link.tsx'
|
|
5
7
|
import { Band } from './row.tsx'
|
|
6
8
|
|
|
7
9
|
function codeText(node: ReactNode): string {
|
|
@@ -45,6 +47,34 @@ function heading(tone: 'bright' | 'fg'): Components['h1'] {
|
|
|
45
47
|
}
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
// An agent writes a file as a markdown link far more often than it writes a URL, and the link text is
|
|
51
|
+
// its name, not its path: an anchor to a scheme-less href resolves against the host page and leads nowhere.
|
|
52
|
+
function Link({ href, children }: { href?: string; children: ReactNode }) {
|
|
53
|
+
const links = useFileLinks()
|
|
54
|
+
const file = links ? parseFileLink(href, links.cwd) : undefined
|
|
55
|
+
if (file) {
|
|
56
|
+
return (
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
className="term-link"
|
|
60
|
+
data-tone="blue"
|
|
61
|
+
title={file.line === undefined ? file.path : `${file.path}:${file.line}`}
|
|
62
|
+
onClick={(event) => {
|
|
63
|
+
event.stopPropagation()
|
|
64
|
+
links?.open(file.path, file.line)
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
{children}
|
|
68
|
+
</button>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
return (
|
|
72
|
+
<a className="term-link" data-tone="blue" href={href} target="_blank" rel="noreferrer">
|
|
73
|
+
{children}
|
|
74
|
+
</a>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
48
78
|
const TERMINAL_COMPONENTS: Components = {
|
|
49
79
|
p: ({ children }) => <div className="term-block">{children}</div>,
|
|
50
80
|
|
|
@@ -85,11 +115,7 @@ const TERMINAL_COMPONENTS: Components = {
|
|
|
85
115
|
</strong>
|
|
86
116
|
),
|
|
87
117
|
em: ({ children }) => <em className="term-em">{children}</em>,
|
|
88
|
-
a: ({ children, href }) =>
|
|
89
|
-
<a className="term-link" data-tone="blue" href={href} target="_blank" rel="noreferrer">
|
|
90
|
-
{children}
|
|
91
|
-
</a>
|
|
92
|
-
),
|
|
118
|
+
a: ({ children, href }) => <Link href={href}>{children}</Link>,
|
|
93
119
|
|
|
94
120
|
table: ({ children }) => (
|
|
95
121
|
<div className="term-block term-table-wrap">
|
|
@@ -351,7 +351,7 @@ export function TerminalScrubber(props: TerminalScrubberProps) {
|
|
|
351
351
|
setPeek(null)
|
|
352
352
|
}, [props.items])
|
|
353
353
|
|
|
354
|
-
// Manual listener because it must `preventDefault`
|
|
354
|
+
// Manual listener because it must `preventDefault` - React's root wheel listeners are passive.
|
|
355
355
|
useEffect(() => {
|
|
356
356
|
if (!interactive) {
|
|
357
357
|
return
|
|
@@ -29,7 +29,7 @@ export function todoPreview(name: string, input: unknown): TodoPreview | undefin
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
// Height and render both draw this exact string
|
|
32
|
+
// Height and render both draw this exact string - `toolRowHeight` counts what `TerminalTodos` paints.
|
|
33
33
|
export function todoLine(todo: TodoEntry): string {
|
|
34
34
|
return `${GLYPH[todo.status]} ${todo.text}`
|
|
35
35
|
}
|
|
@@ -59,7 +59,7 @@ export function Splitter({
|
|
|
59
59
|
}
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
// Reached after `endDrag`
|
|
62
|
+
// Reached after `endDrag` - the second click of a double-click has already started a drag, and dblclick fires after pointerup.
|
|
63
63
|
const onDoubleClick = () => {
|
|
64
64
|
if (defaultValue !== undefined) {
|
|
65
65
|
onValueChange(clamp(defaultValue))
|
package/src/index.ts
CHANGED
|
@@ -61,6 +61,8 @@ export { TerminalDiff, previewPatch } from './components/terminal/diff.tsx'
|
|
|
61
61
|
export { TerminalMarkdown, type TerminalMarkdownProps } from './components/terminal/markdown.tsx'
|
|
62
62
|
export { Band, Blank, Ink, Row, type RowProps, type Tone } from './components/terminal/row.tsx'
|
|
63
63
|
export { CopyAction, WithActions, useAffordances, type TerminalAffordances } from './components/terminal/affordances.tsx'
|
|
64
|
+
export { FileLinkProvider, useFileLinks, type FileLinkHandle, type FileLinkOpener } from './components/terminal/file-link.tsx'
|
|
65
|
+
export { parseFileLink, type FileLink } from './lib/file-link.ts'
|
|
64
66
|
export { Conversation, ConversationContent, ConversationScrollButton, type ConversationProps } from './components/agent/Conversation.tsx'
|
|
65
67
|
export { Message, MessageContent, type MessageProps } from './components/agent/Message.tsx'
|
|
66
68
|
export {
|
|
@@ -86,7 +88,15 @@ export {
|
|
|
86
88
|
type QuestionPromptProps,
|
|
87
89
|
type QuestionBehaviorMeta,
|
|
88
90
|
} from './components/agent/QuestionPrompt.tsx'
|
|
89
|
-
export { Composer,
|
|
91
|
+
export { Composer, type ComposerFileMatch, type ComposerHandle, type ComposerProps } from './components/agent/Composer.tsx'
|
|
92
|
+
export {
|
|
93
|
+
mergeComposerRows,
|
|
94
|
+
rankComposerRows,
|
|
95
|
+
matchClientCommand,
|
|
96
|
+
skillPrompt,
|
|
97
|
+
type ClientCommand,
|
|
98
|
+
type ComposerRow,
|
|
99
|
+
} from './components/agent/composer-commands.ts'
|
|
90
100
|
export { ModelSelect, type ModelSelectProps } from './components/agent/ModelSelect.tsx'
|
|
91
101
|
export {
|
|
92
102
|
PERMISSION_MODES,
|
package/src/lib/context-note.ts
CHANGED
|
@@ -9,11 +9,11 @@ export interface ContextNote {
|
|
|
9
9
|
|
|
10
10
|
const CODEX_CONTEXT_NOTE: ContextNote = {
|
|
11
11
|
summary:
|
|
12
|
-
'Codex budgets 272K input by default and reports 95% of it, so a 1M-class model reads ~258k. That is codex’s price tier, not the model’s ceiling
|
|
13
|
-
hint: 'Raising it is yours to set: model_context_window in ~/.codex/config.toml
|
|
12
|
+
'Codex budgets 272K input by default and reports 95% of it, so a 1M-class model reads ~258k. That is codex’s price tier, not the model’s ceiling - and it is the number auto-compaction fires on, so the meter is honest.',
|
|
13
|
+
hint: 'Raising it is yours to set: model_context_window in ~/.codex/config.toml - and watch for auto-compaction failing afterwards.',
|
|
14
14
|
detail: [
|
|
15
15
|
'To raise it, set model_context_window in ~/.codex/config.toml, up to 872000 (reported as 828400). Codex clamps to its own max_context_window, itself below OpenAI’s stated 922,000 max input.',
|
|
16
|
-
'Above 272K input, a request bills at 2x input / 1.5x output for the whole request. WorkerDeck never writes this setting
|
|
16
|
+
'Above 272K input, a request bills at 2x input / 1.5x output for the whole request. WorkerDeck never writes this setting - it is a cost decision on your account.',
|
|
17
17
|
],
|
|
18
18
|
caveat:
|
|
19
19
|
'Raise it and watch for auto-compaction failing: openai/codex #16068 reports it stops firing after the first overflow. Unverified here on 0.149.0.',
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export type FileLink = {
|
|
2
|
+
path: string
|
|
3
|
+
line?: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const SCHEME = /^[a-zA-Z][a-zA-Z0-9+.-]*:/
|
|
7
|
+
const TRAILING_LINE = /^(.*?):(\d+)(?::\d+)?$/
|
|
8
|
+
const FRAGMENT_LINE = /^L(\d+)/
|
|
9
|
+
|
|
10
|
+
// What an agent writes as a markdown link target when it means a file on the host: `./SPEC.md`,
|
|
11
|
+
// `docs/AUTH.md#anchor`, `/Users/me/project/src/main.ts:42`, `file:///tmp/out.txt`. Anything with a
|
|
12
|
+
// real scheme, and anything relative that arrives before the session's cwd is known, stays a web link.
|
|
13
|
+
export function parseFileLink(href: string | undefined, cwd: string | undefined): FileLink | undefined {
|
|
14
|
+
const raw = href?.trim()
|
|
15
|
+
if (!raw || raw.startsWith('#') || raw.startsWith('//')) {
|
|
16
|
+
return undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let target = raw
|
|
20
|
+
if (SCHEME.test(target)) {
|
|
21
|
+
if (!target.startsWith('file:')) {
|
|
22
|
+
return undefined
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
target = decodeURIComponent(new URL(target).pathname)
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let line: number | undefined
|
|
32
|
+
const hash = target.indexOf('#')
|
|
33
|
+
if (hash !== -1) {
|
|
34
|
+
line = readLine(FRAGMENT_LINE.exec(target.slice(hash + 1))?.[1])
|
|
35
|
+
target = target.slice(0, hash)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const suffix = TRAILING_LINE.exec(target)
|
|
39
|
+
if (suffix) {
|
|
40
|
+
target = suffix[1]!
|
|
41
|
+
line ??= readLine(suffix[2])
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!target) {
|
|
45
|
+
return undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const path = target.startsWith('/') ? normalize(target) : cwd ? normalize(`${cwd}/${target}`) : undefined
|
|
49
|
+
return path ? { path, ...(line === undefined ? {} : { line }) } : undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readLine(value: string | undefined): number | undefined {
|
|
53
|
+
const parsed = value === undefined ? Number.NaN : Number(value)
|
|
54
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalize(path: string): string {
|
|
58
|
+
const out: string[] = []
|
|
59
|
+
for (const part of path.split('/')) {
|
|
60
|
+
if (part === '' || part === '.') {
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (part === '..') {
|
|
64
|
+
out.pop()
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
out.push(part)
|
|
68
|
+
}
|
|
69
|
+
return `/${out.join('/')}`
|
|
70
|
+
}
|
package/src/lib/format.ts
CHANGED
|
@@ -28,7 +28,7 @@ export function compactionText(item: {
|
|
|
28
28
|
|
|
29
29
|
export function formatCost(usd: number | undefined): string {
|
|
30
30
|
if (usd === undefined || Number.isNaN(usd)) {
|
|
31
|
-
return '
|
|
31
|
+
return '-'
|
|
32
32
|
}
|
|
33
33
|
if (usd === 0) {
|
|
34
34
|
return '$0.00'
|
|
@@ -92,7 +92,7 @@ export function formatCountdown(untilEpochMs: number, now = Date.now()): string
|
|
|
92
92
|
|
|
93
93
|
export function formatRelativeTime(epochMs: number | undefined, now = Date.now()): string {
|
|
94
94
|
if (!epochMs) {
|
|
95
|
-
return '
|
|
95
|
+
return '-'
|
|
96
96
|
}
|
|
97
97
|
const diff = Math.max(0, now - epochMs)
|
|
98
98
|
const s = Math.floor(diff / 1000)
|