@workerdeck/ui 0.22.0 → 1.0.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/build/{SessionPanel-DgwjH4Ve.mjs → SessionPanel-DDgBkHsi.mjs} +2196 -3364
- package/build/SessionPanel-DDgBkHsi.mjs.map +1 -0
- package/build/SessionPanel-E_L_ldQ5.d.mts +207 -0
- package/build/format-B8ZM9LPy.d.mts +16 -0
- package/build/format.d.mts +8 -75
- package/build/format.mjs +2 -2
- package/build/index.d.mts +63 -1138
- package/build/index.mjs +4 -300
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +80 -24
- package/build/{status-BE-zg88x.mjs → status-C0HSLZas.mjs} +14 -85
- package/build/status-C0HSLZas.mjs.map +1 -0
- package/build/workspace.d.mts +9 -124
- package/build/workspace.mjs +13 -143
- package/build/workspace.mjs.map +1 -1
- package/package.json +4 -4
- package/src/components/agent/CodeEditor.tsx +31 -103
- package/src/components/agent/Composer.tsx +114 -333
- package/src/components/agent/ContextDialog.tsx +20 -41
- package/src/components/agent/ContextRing.tsx +6 -28
- package/src/components/agent/Conversation.tsx +21 -33
- package/src/components/agent/EditorTabs.tsx +39 -67
- package/src/components/agent/EngineIcon.tsx +29 -72
- package/src/components/agent/EntryIcon.tsx +16 -0
- package/src/components/agent/FileCard.tsx +10 -16
- package/src/components/agent/FileTree.tsx +52 -125
- package/src/components/agent/FileViewer.tsx +30 -60
- package/src/components/agent/HostFilesDialog.tsx +40 -69
- package/src/components/agent/Loader.tsx +3 -24
- package/src/components/agent/McpDialog.tsx +64 -137
- package/src/components/agent/Message.tsx +2 -21
- package/src/components/agent/ModelSelect.tsx +33 -62
- package/src/components/agent/PermissionModeSelect.tsx +21 -79
- package/src/components/agent/PermissionPrompt.tsx +59 -63
- package/src/components/agent/ProjectIcon.tsx +228 -93
- package/src/components/agent/PromptTokenText.tsx +10 -16
- package/src/components/agent/QuestionPrompt.tsx +43 -64
- package/src/components/agent/Reasoning.tsx +13 -16
- package/src/components/agent/Response.tsx +4 -6
- package/src/components/agent/Scrubber.tsx +31 -80
- package/src/components/agent/SessionBrowser.tsx +64 -243
- package/src/components/agent/SessionEmptyState.tsx +10 -40
- package/src/components/agent/SessionInfoDialog.tsx +46 -78
- package/src/components/agent/SessionItem.tsx +53 -320
- package/src/components/agent/SessionList.tsx +23 -39
- package/src/components/agent/SessionPanel.tsx +345 -923
- package/src/components/agent/SessionStatusIcon.tsx +14 -28
- package/src/components/agent/SessionSteps.tsx +34 -187
- package/src/components/agent/SessionWorkspace.tsx +108 -215
- package/src/components/agent/SkillsDialog.tsx +35 -80
- package/src/components/agent/StatusBar.tsx +51 -136
- package/src/components/agent/SubagentStrip.tsx +12 -77
- package/src/components/agent/ToolCallCard.tsx +94 -135
- package/src/components/agent/Transcript.tsx +103 -1288
- package/src/components/agent/TranscriptItemView.tsx +155 -0
- package/src/components/agent/TranscriptRows.tsx +418 -0
- package/src/components/agent/UsageDialog.tsx +7 -29
- package/src/components/agent/UsageMeters.tsx +18 -73
- package/src/components/agent/pulse.tsx +9 -32
- package/src/components/agent/scrubber-marks.ts +33 -103
- package/src/components/agent/status.ts +1 -5
- package/src/components/agent/tool-result-fetch.tsx +3 -25
- package/src/components/agent/tool-result-image.tsx +32 -111
- package/src/components/agent/transcript-rows.ts +41 -118
- package/src/components/agent/transcript-variant.tsx +3 -80
- package/src/components/agent/use-height-epoch.ts +10 -24
- package/src/components/agent/use-path-links.ts +45 -63
- package/src/components/agent/use-subagent-frame.ts +106 -0
- package/src/components/agent/use-transcript-jumps.ts +49 -107
- package/src/components/prompt-area/animated-placeholder.tsx +6 -5
- package/src/components/prompt-area/clipboard-helpers.ts +26 -21
- package/src/components/prompt-area/cursor-helpers.ts +79 -37
- package/src/components/prompt-area/dom-helpers.ts +97 -69
- package/src/components/prompt-area/file-strip.tsx +90 -80
- package/src/components/prompt-area/html-to-markdown.ts +67 -46
- package/src/components/prompt-area/image-strip.tsx +8 -18
- package/src/components/prompt-area/index.ts +2 -15
- package/src/components/prompt-area/prompt-area-engine.ts +100 -115
- package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
- package/src/components/prompt-area/prompt-area.tsx +31 -38
- package/src/components/prompt-area/remove-button.tsx +3 -9
- package/src/components/prompt-area/segment-helpers.ts +4 -12
- package/src/components/prompt-area/trigger-popover.tsx +15 -27
- package/src/components/prompt-area/trigger-presets.ts +0 -10
- package/src/components/prompt-area/types.ts +3 -8
- package/src/components/prompt-area/use-chip-editing.ts +305 -0
- package/src/components/prompt-area/use-markdown-mode.ts +7 -17
- package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
- package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
- package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
- package/src/components/prompt-area/use-prompt-area.ts +170 -896
- package/src/components/prompt-area/use-trigger-search.ts +21 -19
- package/src/components/terminal/PermissionPrompt.tsx +56 -59
- package/src/components/terminal/QuestionPrompt.tsx +28 -79
- package/src/components/terminal/StatusLine.tsx +13 -63
- package/src/components/terminal/TerminalTranscript.tsx +44 -160
- package/src/components/terminal/affordances.tsx +68 -98
- package/src/components/terminal/blocks.ts +20 -156
- package/src/components/terminal/diff.tsx +19 -67
- package/src/components/terminal/height.ts +163 -313
- package/src/components/terminal/image-box.ts +0 -44
- package/src/components/terminal/items.tsx +150 -305
- package/src/components/terminal/markdown.tsx +36 -95
- package/src/components/terminal/press.tsx +26 -53
- package/src/components/terminal/prompt.tsx +40 -170
- package/src/components/terminal/result-preview.ts +8 -56
- package/src/components/terminal/row.tsx +11 -98
- package/src/components/terminal/scrubber.tsx +108 -401
- package/src/components/terminal/surface.tsx +6 -54
- package/src/components/terminal/todos.ts +66 -0
- package/src/components/terminal/tool-run.ts +21 -192
- package/src/components/ui/AlertDialog.tsx +8 -23
- package/src/components/ui/Badge.tsx +18 -29
- package/src/components/ui/Button.tsx +7 -15
- package/src/components/ui/Card.tsx +2 -13
- package/src/components/ui/CodeBlock.tsx +10 -37
- package/src/components/ui/CopyButton.tsx +10 -23
- package/src/components/ui/Dialog.tsx +23 -36
- package/src/components/ui/Empty.tsx +6 -29
- package/src/components/ui/Input.tsx +14 -16
- package/src/components/ui/Menu.tsx +6 -18
- package/src/components/ui/PortalScope.tsx +1 -21
- package/src/components/ui/ProgressRing.tsx +8 -19
- package/src/components/ui/Select.tsx +18 -31
- package/src/components/ui/Sonner.tsx +1 -2
- package/src/components/ui/Spinner.tsx +1 -1
- package/src/components/ui/Splitter.tsx +30 -64
- package/src/components/ui/Textarea.tsx +2 -5
- package/src/components/ui/Tooltip.tsx +3 -13
- package/src/format.ts +0 -9
- package/src/index.ts +16 -113
- package/src/lib/clipboard.ts +8 -28
- package/src/lib/css.ts +8 -0
- package/src/lib/format.ts +101 -86
- package/src/lib/plan-request.ts +16 -0
- package/src/lib/status.ts +45 -95
- package/src/lib/tool-icon.ts +39 -47
- package/src/lib/utils.ts +1 -3
- package/src/styles/terminal.css +81 -49
- package/src/styles/theme.css +136 -19
- package/src/workspace.ts +1 -24
- package/build/SessionPanel-13l25ubU.d.mts +0 -609
- package/build/SessionPanel-DgwjH4Ve.mjs.map +0 -1
- package/build/format-ljc3lKpA.d.mts +0 -59
- package/build/status-BE-zg88x.mjs.map +0 -1
|
@@ -1,53 +1,9 @@
|
|
|
1
1
|
import { formatBytes } from '../../lib/format.ts'
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* The box a tool result's image is drawn in, and the words drawn in it before
|
|
5
|
-
* the bytes arrive.
|
|
6
|
-
*
|
|
7
|
-
* Its own module, and pure, for `result-preview.ts`'s reason with a constant
|
|
8
|
-
* standing where a string stood: `items.tsx` draws these boxes and `height.ts`
|
|
9
|
-
* predicts their pixel height for the virtualizer's `estimateSize` without a
|
|
10
|
-
* DOM. Two spellings of the box would be two different heights, and the row
|
|
11
|
-
* would grow or shrink the moment it mounted.
|
|
12
|
-
*
|
|
13
|
-
* **A fixed box, sized in whole lines, reserved from plan time.** An image's
|
|
14
|
-
* intrinsic dimensions are not knowable before its bytes are, and the
|
|
15
|
-
* alternative — an `exact: false` row corrected on mount — would demote *most*
|
|
16
|
-
* rows of an image-bearing session to estimates and bring back the growing
|
|
17
|
-
* scrollbar the calculator exists to kill. A box that does not depend on what is
|
|
18
|
-
* inside it is exact by definition, at the cost of some letterboxing.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Whole lines per image. 12 ≈ 240px at an 18px line — big enough that a
|
|
23
|
-
* screenshot is legible as *what it is* (which is the whole reason images became
|
|
24
|
-
* visible at all), small enough that a call returning four of them does not
|
|
25
|
-
* become a screenful.
|
|
26
|
-
*/
|
|
27
3
|
export const IMAGE_BOX_LINES = 12
|
|
28
4
|
|
|
29
|
-
/**
|
|
30
|
-
* What the box says before the fetch lands.
|
|
31
|
-
*
|
|
32
|
-
* `bytes` is the decoded size the gateway stamped on the reference — the client
|
|
33
|
-
* holds no bytes at all until it asks for them, so this is a number it cannot
|
|
34
|
-
* compute, the same reason `total_chars` rides beside a truncated head.
|
|
35
|
-
* `formatBytes` rather than a spelling of its own: the panel says "336.0 KB"
|
|
36
|
-
* everywhere else, and a second byte formatter is a second thing to keep in
|
|
37
|
-
* step.
|
|
38
|
-
*/
|
|
39
5
|
export function imagePlaceholder(image: { bytes: number }): string {
|
|
40
6
|
return `image · ${formatBytes(image.bytes)}`
|
|
41
7
|
}
|
|
42
8
|
|
|
43
|
-
/**
|
|
44
|
-
* What it says when the fetch failed — a stale address after a dormant wake
|
|
45
|
-
* (the route 404s rather than serving another call's pixels), a gateway too old
|
|
46
|
-
* to know the route, a dropped connection.
|
|
47
|
-
*
|
|
48
|
-
* It occupies the same box, because the alternative is the row changing height
|
|
49
|
-
* on a network failure. Silence is not an option here the way it is for
|
|
50
|
-
* `HostImage`: that card names the host path in its result text, so a reader can
|
|
51
|
-
* still find the picture; a replayed image part has no path to name.
|
|
52
|
-
*/
|
|
53
9
|
export const IMAGE_UNAVAILABLE = 'image unavailable'
|
|
@@ -3,7 +3,7 @@ import type { TranscriptItem } from '@workerdeck/react'
|
|
|
3
3
|
import { formatBytes, formatCost, formatDuration, toolInputPreview } from '../../lib/format.ts'
|
|
4
4
|
import { isMutatingTool } from '../../lib/tool-icon.ts'
|
|
5
5
|
import { usePulse } from '../agent/pulse.tsx'
|
|
6
|
-
import { CopyAction, WithActions } from './affordances.tsx'
|
|
6
|
+
import { BookmarkAction, CopyAction, WithActions } from './affordances.tsx'
|
|
7
7
|
import { TerminalDiff } from './diff.tsx'
|
|
8
8
|
import { TerminalMarkdown } from './markdown.tsx'
|
|
9
9
|
import { Pressable, useRevealOnOpen } from './press.tsx'
|
|
@@ -12,76 +12,21 @@ import { collapsedResult } from './result-preview.ts'
|
|
|
12
12
|
import { useToolResultFetcher } from '../agent/tool-result-fetch.tsx'
|
|
13
13
|
import { useToolResultImageSrc } from '../agent/tool-result-image.tsx'
|
|
14
14
|
import { runFailed, runSummary } from './tool-run.ts'
|
|
15
|
+
import { todoLine, todoPreview, type TodoPreview, type TodoStatus } from './todos.ts'
|
|
15
16
|
import { type ToolCallItem } from './blocks.ts'
|
|
16
17
|
import { Band, Blank, Ink, Row, type Tone } from './row.tsx'
|
|
17
18
|
|
|
18
|
-
// The pure block model — which rows exist — lives in `blocks.ts` now that a
|
|
19
|
-
// task block made it non-trivial; re-exported here because this file is where
|
|
20
|
-
// consumers have always found it.
|
|
21
|
-
export {
|
|
22
|
-
blockNeedsBlank,
|
|
23
|
-
isRunCall,
|
|
24
|
-
needsBlank,
|
|
25
|
-
parentOf,
|
|
26
|
-
taskChildItems,
|
|
27
|
-
terminalBlocks,
|
|
28
|
-
type ItemBlock,
|
|
29
|
-
type LeafBlock,
|
|
30
|
-
type RunBlock,
|
|
31
|
-
type TaskBlock,
|
|
32
|
-
type TerminalBlock,
|
|
33
|
-
type ToolCallItem,
|
|
34
|
-
} from './blocks.ts'
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* One transcript item, drawn as terminal rows.
|
|
38
|
-
*
|
|
39
|
-
* Each renderer here answers the same two questions the CLI answers: which
|
|
40
|
-
* marker goes in the gutter, and what the body says. Nothing chooses a spacing,
|
|
41
|
-
* a radius or a border — a row is a row, and the space between blocks is a
|
|
42
|
-
* {@link Blank} decided by the transcript, which is the only thing that knows
|
|
43
|
-
* whether two blocks belong together.
|
|
44
|
-
*
|
|
45
|
-
* The markers are the CLI's:
|
|
46
|
-
*
|
|
47
|
-
* | glyph | means |
|
|
48
|
-
* |-------|------------------------------------------|
|
|
49
|
-
* | `❯` | what you typed |
|
|
50
|
-
* | `●` | what the model said, or a tool it called |
|
|
51
|
-
* | `⎿` | that tool's output, one level in |
|
|
52
|
-
* | `✻` | thinking |
|
|
53
|
-
* | `!` | a notice from the runner, not the model |
|
|
54
|
-
*/
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* The prompt marker, in the transcript and in the composer both.
|
|
58
|
-
*
|
|
59
|
-
* `❯` rather than `>`: it is the shell prompt of every terminal anyone has
|
|
60
|
-
* configured this decade, and it reads as a *prompt* where `>` reads as a
|
|
61
|
-
* quotation or a greater-than. Exported because the composer is the same
|
|
62
|
-
* marker in the same gutter cell — that is the whole claim of the terminal
|
|
63
|
-
* composer, and two spellings of it would put the caret one glyph off the
|
|
64
|
-
* column the rows above start on.
|
|
65
|
-
*/
|
|
66
19
|
export const PROMPT_GLYPH = '❯'
|
|
67
20
|
|
|
68
|
-
/** How much the expanded row shows before offering the rest. The collapsed
|
|
69
|
-
* budget is `collapsedResult`'s, shared with the height calculator.
|
|
70
|
-
*
|
|
71
|
-
* Exported for one test and not from the package: protocol's
|
|
72
|
-
* `TOOL_RESULT_HEAD_CHARS` is chosen to exceed it, so that a truncated result's
|
|
73
|
-
* open state is byte-identical to an untruncated one and only the uncapped
|
|
74
|
-
* `full` press ever fetches. That relationship is asserted, not assumed. */
|
|
75
21
|
export const RESULT_PREVIEW_CHARS = 2000
|
|
76
22
|
|
|
77
|
-
/** Whole lines up to a character budget — never zero, because a single line
|
|
78
|
-
* longer than the budget still has to be shown or the row would open onto
|
|
79
|
-
* nothing. */
|
|
80
23
|
function clipToChars(lines: string[], maxChars: number): string[] {
|
|
81
24
|
const out: string[] = []
|
|
82
25
|
let chars = 0
|
|
83
26
|
for (const line of lines) {
|
|
84
|
-
if (out.length > 0 && chars + line.length > maxChars)
|
|
27
|
+
if (out.length > 0 && chars + line.length > maxChars) {
|
|
28
|
+
break
|
|
29
|
+
}
|
|
85
30
|
out.push(line)
|
|
86
31
|
chars += line.length + 1
|
|
87
32
|
}
|
|
@@ -90,42 +35,36 @@ function clipToChars(lines: string[], maxChars: number): string[] {
|
|
|
90
35
|
|
|
91
36
|
export function UserRow({ item }: { item: Extract<TranscriptItem, { kind: 'user' }> }) {
|
|
92
37
|
return (
|
|
93
|
-
<
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
{
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
</Row>
|
|
110
|
-
))
|
|
111
|
-
: null}
|
|
112
|
-
</div>
|
|
38
|
+
<WithActions actions={<BookmarkAction id={item.id} />}>
|
|
39
|
+
<div className="term-user">
|
|
40
|
+
{item.attachments?.length ? (
|
|
41
|
+
<Row glyph={PROMPT_GLYPH} glyphTone="dim" tone="dim">
|
|
42
|
+
{item.attachments.map((attachment) => attachment.name).join(', ')}
|
|
43
|
+
</Row>
|
|
44
|
+
) : null}
|
|
45
|
+
{item.text
|
|
46
|
+
? item.text.split('\n').map((line, index) => (
|
|
47
|
+
<Row key={index} glyph={index === 0 ? PROMPT_GLYPH : undefined} glyphTone="dim" tone="fg">
|
|
48
|
+
{line || ' '}
|
|
49
|
+
</Row>
|
|
50
|
+
))
|
|
51
|
+
: null}
|
|
52
|
+
</div>
|
|
53
|
+
</WithActions>
|
|
113
54
|
)
|
|
114
55
|
}
|
|
115
56
|
|
|
116
|
-
export function AssistantRow({
|
|
117
|
-
item,
|
|
118
|
-
}: {
|
|
119
|
-
item: Extract<TranscriptItem, { kind: 'assistant_text' }>
|
|
120
|
-
}) {
|
|
57
|
+
export function AssistantRow({ item }: { item: Extract<TranscriptItem, { kind: 'assistant_text' }> }) {
|
|
121
58
|
return (
|
|
122
|
-
// Copy the markdown source, not the rendered text: what you paste into an
|
|
123
|
-
// issue or a commit message should keep its lists and its code fences.
|
|
124
|
-
// Absent while streaming — half a message is not a thing anyone wants on
|
|
125
|
-
// their clipboard, and the button would appear mid-sentence.
|
|
126
59
|
<WithActions
|
|
127
|
-
actions={
|
|
128
|
-
|
|
60
|
+
actions={
|
|
61
|
+
<>
|
|
62
|
+
<BookmarkAction id={item.id} />
|
|
63
|
+
{item.streaming ? null : <CopyAction text={item.text} label="Copy message" />}
|
|
64
|
+
</>
|
|
65
|
+
}
|
|
66
|
+
>
|
|
67
|
+
<Row glyph="●" glyphTone="fg" tone="fg">
|
|
129
68
|
<TerminalMarkdown streaming={item.streaming}>{item.text}</TerminalMarkdown>
|
|
130
69
|
</Row>
|
|
131
70
|
</WithActions>
|
|
@@ -134,13 +73,12 @@ export function AssistantRow({
|
|
|
134
73
|
|
|
135
74
|
export function ThinkingRow({ item }: { item: Extract<TranscriptItem, { kind: 'thinking' }> }) {
|
|
136
75
|
return (
|
|
137
|
-
<Row glyph=
|
|
138
|
-
<span className=
|
|
76
|
+
<Row glyph="✻" glyphTone="dim" tone="dim">
|
|
77
|
+
<span className="term-em">{item.text}</span>
|
|
139
78
|
</Row>
|
|
140
79
|
)
|
|
141
80
|
}
|
|
142
81
|
|
|
143
|
-
/** The gutter dot's colour: the call's state, said without a badge. */
|
|
144
82
|
const TOOL_TONE: Record<string, Tone> = {
|
|
145
83
|
running: 'blue',
|
|
146
84
|
pending: 'blue',
|
|
@@ -152,208 +90,151 @@ const TOOL_TONE: Record<string, Tone> = {
|
|
|
152
90
|
export function ToolRow({ item }: { item: ToolCallItem }) {
|
|
153
91
|
const [open, setOpen] = useState(false)
|
|
154
92
|
const [full, setFull] = useState(false)
|
|
155
|
-
// Set while the rest of a truncated result is in flight. Row-local, unlike the
|
|
156
|
-
// text itself, which lands in transcript state — this is a spinner, not a
|
|
157
|
-
// fact about the session.
|
|
158
93
|
const [fetching, setFetching] = useState(false)
|
|
159
94
|
const fetchResult = useToolResultFetcher()
|
|
160
95
|
const reveal = useRevealOnOpen(open)
|
|
161
96
|
const status = item.status ?? (item.result === undefined ? 'running' : 'settled')
|
|
162
97
|
const busy = status === 'running' || status === 'pending'
|
|
163
98
|
const isError = status === 'failed' || item.result?.isError === true
|
|
164
|
-
// Ticks only while this row is really running: an idle transcript of a hundred
|
|
165
|
-
// settled calls starts no timers at all.
|
|
166
99
|
const pulse = usePulse(busy)
|
|
167
100
|
|
|
168
101
|
const text = item.result?.text ?? ''
|
|
169
102
|
const lines = text.trimEnd().split('\n')
|
|
170
|
-
// Three states, not two. Collapsed shows a few lines; open shows the output up
|
|
171
|
-
// to a character budget; `full` lifts the budget. The middle one is the reason
|
|
172
|
-
// the budget exists at all: a tool result can be a hundred thousand characters
|
|
173
|
-
// (a test run, a `find /`), and the whole of it lands in **one** virtual row —
|
|
174
|
-
// the virtualizer mounts rows, so it cannot help with what is inside a single
|
|
175
|
-
// one. Without the clip, expanding one row commits thousands of DOM nodes and
|
|
176
|
-
// the transcript stops being smooth for the rest of the session.
|
|
177
|
-
// The true total when the replay delivered only a head — the row must count
|
|
178
|
-
// what is missing, not what it happens to hold.
|
|
179
103
|
const collapsed = collapsedResult(lines, item.result?.totalChars)
|
|
180
|
-
const preview = open
|
|
181
|
-
? full
|
|
182
|
-
? lines
|
|
183
|
-
: clipToChars(lines, RESULT_PREVIEW_CHARS)
|
|
184
|
-
: collapsed.shown
|
|
104
|
+
const preview = open ? (full ? lines : clipToChars(lines, RESULT_PREVIEW_CHARS)) : collapsed.shown
|
|
185
105
|
const hidden = lines.length - preview.length
|
|
186
106
|
const clipped = open && !full && hidden > 0
|
|
187
|
-
// The replay sent a head. `full` then means "fetch the rest", not "lift the
|
|
188
|
-
// clip" — and the marker outlives the clip, because a head short enough to fit
|
|
189
|
-
// the open budget still is not the result.
|
|
190
107
|
const truncated = item.result?.truncated === true
|
|
191
108
|
const missing = truncated ? (item.result?.totalChars ?? 0) - text.length : 0
|
|
192
109
|
|
|
193
|
-
const tone: Tone = isError
|
|
194
|
-
? 'red'
|
|
195
|
-
: // A settled write is green: skimming a run, "what did it change" is the
|
|
196
|
-
// question you come back to, and the one you might need to undo.
|
|
197
|
-
status === 'settled' && isMutatingTool(item.name)
|
|
198
|
-
? 'green'
|
|
199
|
-
: (TOOL_TONE[status] ?? 'dim')
|
|
110
|
+
const tone: Tone = isError ? 'red' : status === 'settled' && isMutatingTool(item.name) ? 'green' : (TOOL_TONE[status] ?? 'dim')
|
|
200
111
|
|
|
201
|
-
// What is worth having on the clipboard from a tool call is the command you
|
|
202
|
-
// would re-run, when there is one, and otherwise its output.
|
|
203
112
|
const command = (item.input as { command?: unknown } | null)?.command
|
|
204
113
|
const copyable = typeof command === 'string' ? command : text
|
|
114
|
+
const todos = todoPreview(item.name, item.input)
|
|
205
115
|
|
|
206
116
|
return (
|
|
207
|
-
// Open, the whole block keeps a fill: an expansion that runs past the top of
|
|
208
|
-
// the screen otherwise leaves no mark of where it began, and the reader has
|
|
209
|
-
// to guess which rows they opened.
|
|
210
117
|
<div ref={reveal} className={open ? 'term-open' : undefined}>
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
118
|
+
<WithActions
|
|
119
|
+
actions={
|
|
120
|
+
<>
|
|
121
|
+
<BookmarkAction id={item.id} />
|
|
122
|
+
{copyable ? <CopyAction text={copyable} label="Copy" /> : null}
|
|
123
|
+
</>
|
|
124
|
+
}
|
|
125
|
+
>
|
|
126
|
+
<Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
|
|
127
|
+
<Row glyph={busy ? pulse : '●'} glyphTone={tone} tone="fg">
|
|
128
|
+
<Ink bold tone="bright">
|
|
129
|
+
{item.name}
|
|
130
|
+
</Ink>
|
|
131
|
+
<Ink tone="dim">({todos ? todos.summary : toolInputPreview(item.input)})</Ink>
|
|
132
|
+
{item.backend && item.backend !== 'server' ? <Ink tone="faint"> · {item.backend}</Ink> : null}
|
|
133
|
+
</Row>
|
|
134
|
+
</Pressable>
|
|
135
|
+
{item.result?.images?.map((image) => (
|
|
136
|
+
<TerminalImage key={image.partIndex} toolUseId={item.id} image={image} />
|
|
137
|
+
))}
|
|
138
|
+
{item.patch && !open ? (
|
|
139
|
+
<TerminalDiff patch={item.patch} />
|
|
140
|
+
) : todos && !open ? (
|
|
141
|
+
<TerminalTodos preview={todos} />
|
|
142
|
+
) : text ? (
|
|
143
|
+
<>
|
|
144
|
+
{preview.map((line, index) => (
|
|
145
|
+
<Row key={index} indent={1} columns={3} glyph={index === 0 ? '⎿' : undefined} tone={isError ? 'red' : 'dim'}>
|
|
146
|
+
{line || ' '}
|
|
147
|
+
</Row>
|
|
148
|
+
))}
|
|
149
|
+
{!open ? (
|
|
150
|
+
collapsed.more ? (
|
|
151
|
+
<Row indent={1} columns={3} tone="faint">
|
|
152
|
+
{collapsed.more}
|
|
153
|
+
</Row>
|
|
154
|
+
) : null
|
|
155
|
+
) : clipped || truncated ? (
|
|
156
|
+
<Row indent={1} columns={3} tone="faint">
|
|
157
|
+
{fetching ? (
|
|
158
|
+
<>… fetching {(item.result?.totalChars ?? 0).toLocaleString()} chars</>
|
|
159
|
+
) : clipped || truncated ? (
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
className="term-press term-link"
|
|
163
|
+
onClick={() => {
|
|
164
|
+
setFull(true)
|
|
165
|
+
if (!truncated) {
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
setFetching(true)
|
|
169
|
+
void fetchResult(item.id).finally(() => setFetching(false))
|
|
170
|
+
}}
|
|
171
|
+
>
|
|
172
|
+
{truncated
|
|
173
|
+
? `… +${missing.toLocaleString()} chars — fetch the rest`
|
|
174
|
+
: `… +${hidden} line${hidden === 1 ? '' : 's'} — show all ${text.length.toLocaleString()} chars`}
|
|
175
|
+
</button>
|
|
176
|
+
) : (
|
|
177
|
+
<>
|
|
178
|
+
… +{hidden} line{hidden === 1 ? '' : 's'}
|
|
179
|
+
</>
|
|
180
|
+
)}
|
|
181
|
+
</Row>
|
|
182
|
+
) : hidden > 0 ? (
|
|
183
|
+
<Row indent={1} columns={3} tone="faint">
|
|
184
|
+
… +{hidden} line{hidden === 1 ? '' : 's'}
|
|
185
|
+
</Row>
|
|
186
|
+
) : null}
|
|
187
|
+
</>
|
|
188
|
+
) : null}
|
|
189
|
+
</WithActions>
|
|
190
|
+
</div>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const TODO_TONE: Record<TodoStatus, Tone> = {
|
|
195
|
+
pending: 'dim',
|
|
196
|
+
in_progress: 'blue',
|
|
197
|
+
completed: 'faint',
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function TerminalTodos({ preview }: { preview: TodoPreview }) {
|
|
201
|
+
return (
|
|
202
|
+
<>
|
|
203
|
+
{preview.shown.map((todo, index) => (
|
|
204
|
+
<Row key={index} indent={1} columns={3} glyph={index === 0 ? '⎿' : undefined} tone={TODO_TONE[todo.status]}>
|
|
205
|
+
{todoLine(todo)}
|
|
222
206
|
</Row>
|
|
223
|
-
</Pressable>
|
|
224
|
-
{/* Above the output, because when a call returned a picture the picture is
|
|
225
|
-
what the call was: a screenshot's result text is "took a screenshot".
|
|
226
|
-
Drawn collapsed as well as open — this is not detail behind a press,
|
|
227
|
-
it is the answer. */}
|
|
228
|
-
{item.result?.images?.map((image) => (
|
|
229
|
-
<TerminalImage key={image.partIndex} toolUseId={item.id} image={image} />
|
|
230
207
|
))}
|
|
231
|
-
{
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
<TerminalDiff patch={item.patch} />
|
|
236
|
-
) : text ? (
|
|
237
|
-
<>
|
|
238
|
-
{preview.map((line, index) => (
|
|
239
|
-
<Row
|
|
240
|
-
key={index}
|
|
241
|
-
indent={1}
|
|
242
|
-
columns={3}
|
|
243
|
-
glyph={index === 0 ? '⎿' : undefined}
|
|
244
|
-
tone={isError ? 'red' : 'dim'}>
|
|
245
|
-
{line || ' '}
|
|
246
|
-
</Row>
|
|
247
|
-
))}
|
|
248
|
-
{/* One row for "there is more", pressable exactly when pressing it
|
|
249
|
-
would do something. Collapsed, the count is a label — the header
|
|
250
|
-
above is already the toggle, and a second control for the same act
|
|
251
|
-
is one too many. Open and clipped, it is the way to the rest.
|
|
252
|
-
Collapsed spells its own label (it may be counting characters
|
|
253
|
-
rather than lines, having cut inside one), and it is the string
|
|
254
|
-
`height.ts` sizes the row from. */}
|
|
255
|
-
{!open ? (
|
|
256
|
-
collapsed.more ? (
|
|
257
|
-
<Row indent={1} columns={3} tone='faint'>
|
|
258
|
-
{collapsed.more}
|
|
259
|
-
</Row>
|
|
260
|
-
) : null
|
|
261
|
-
) : clipped || truncated ? (
|
|
262
|
-
<Row indent={1} columns={3} tone='faint'>
|
|
263
|
-
{fetching ? (
|
|
264
|
-
// Never a row that does nothing when pressed: it says what it is
|
|
265
|
-
// doing instead. See `planToolCall`'s comment on the same rule.
|
|
266
|
-
<>… fetching {(item.result?.totalChars ?? 0).toLocaleString()} chars</>
|
|
267
|
-
) : clipped || truncated ? (
|
|
268
|
-
<button
|
|
269
|
-
type='button'
|
|
270
|
-
className='term-press term-link'
|
|
271
|
-
onClick={() => {
|
|
272
|
-
// One press, two acts, in the order that keeps the row
|
|
273
|
-
// honest: lift the clip immediately (that part is local and
|
|
274
|
-
// instant), and fetch the rest when there is a rest. The
|
|
275
|
-
// fetched text lands in transcript state, so the row
|
|
276
|
-
// re-renders with the marker gone.
|
|
277
|
-
setFull(true)
|
|
278
|
-
if (!truncated) return
|
|
279
|
-
setFetching(true)
|
|
280
|
-
void fetchResult(item.id).finally(() => setFetching(false))
|
|
281
|
-
}}>
|
|
282
|
-
{truncated
|
|
283
|
-
? `… +${missing.toLocaleString()} chars — fetch the rest`
|
|
284
|
-
: `… +${hidden} line${hidden === 1 ? '' : 's'} — show all ${text.length.toLocaleString()} chars`}
|
|
285
|
-
</button>
|
|
286
|
-
) : (
|
|
287
|
-
<>
|
|
288
|
-
… +{hidden} line{hidden === 1 ? '' : 's'}
|
|
289
|
-
</>
|
|
290
|
-
)}
|
|
291
|
-
</Row>
|
|
292
|
-
) : hidden > 0 ? (
|
|
293
|
-
<Row indent={1} columns={3} tone='faint'>
|
|
294
|
-
… +{hidden} line{hidden === 1 ? '' : 's'}
|
|
295
|
-
</Row>
|
|
296
|
-
) : null}
|
|
297
|
-
</>
|
|
208
|
+
{preview.more ? (
|
|
209
|
+
<Row indent={1} columns={3} tone="faint">
|
|
210
|
+
{preview.more}
|
|
211
|
+
</Row>
|
|
298
212
|
) : null}
|
|
299
|
-
|
|
300
|
-
</div>
|
|
213
|
+
</>
|
|
301
214
|
)
|
|
302
215
|
}
|
|
303
216
|
|
|
304
|
-
/** One image part of a tool result, as the reducer holds it. */
|
|
305
217
|
type ToolResultImage = NonNullable<NonNullable<ToolCallItem['result']>['images']>[number]
|
|
306
218
|
|
|
307
|
-
/**
|
|
308
|
-
* A picture a tool returned, in a box of {@link IMAGE_BOX_LINES} whole lines.
|
|
309
|
-
*
|
|
310
|
-
* **Three states, one height.** Before the fetch lands the box is a wash and the
|
|
311
|
-
* size the gateway declared; after it, the picture, letterboxed inside the same
|
|
312
|
-
* box; on a refusal, `image unavailable` in it. Nothing here may ever collapse
|
|
313
|
-
* to nothing — that is `HostImage`'s return-null-then-pop, which in a
|
|
314
|
-
* *virtualized* list is not a flicker but a reflow of every row below it, and
|
|
315
|
-
* the height calculator would have been lying about the row from plan time.
|
|
316
|
-
*
|
|
317
|
-
* The box is why the calculator can stay exact: it is a constant, not a function
|
|
318
|
-
* of pixels nobody has downloaded yet.
|
|
319
|
-
*/
|
|
320
219
|
function TerminalImage({ toolUseId, image }: { toolUseId: string; image: ToolResultImage }) {
|
|
321
220
|
const { src, failed } = useToolResultImageSrc({ toolUseId, ...image })
|
|
322
221
|
return (
|
|
323
222
|
<Row indent={1} columns={3}>
|
|
324
223
|
<div
|
|
325
|
-
className=
|
|
224
|
+
className="term-image"
|
|
326
225
|
data-state={src ? 'loaded' : failed ? 'failed' : 'pending'}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
// this box.
|
|
330
|
-
style={{ height: `calc(var(--term-line) * ${IMAGE_BOX_LINES})` }}>
|
|
226
|
+
style={{ height: `calc(var(--term-line) * ${IMAGE_BOX_LINES})` }}
|
|
227
|
+
>
|
|
331
228
|
{src ? (
|
|
332
229
|
<img src={src} alt={imagePlaceholder(image)} />
|
|
333
230
|
) : (
|
|
334
|
-
<Ink tone=
|
|
231
|
+
<Ink tone="faint">{failed ? IMAGE_UNAVAILABLE : imagePlaceholder(image)}</Ink>
|
|
335
232
|
)}
|
|
336
233
|
</div>
|
|
337
234
|
</Row>
|
|
338
235
|
)
|
|
339
236
|
}
|
|
340
237
|
|
|
341
|
-
/**
|
|
342
|
-
* A run of tool calls, as one line.
|
|
343
|
-
*
|
|
344
|
-
* The CLI's own compression, and the reason it works: a tool call is almost
|
|
345
|
-
* never what you came back to read. `Bash(pnpm -w typecheck)` and forty lines of
|
|
346
|
-
* its output say nothing the model's next sentence doesn't say better, and six
|
|
347
|
-
* of them in a row bury that sentence a screen and a half down. So a run
|
|
348
|
-
* collapses to its count and gets out of the way — and opens, in full, the
|
|
349
|
-
* moment it is the thing you actually want.
|
|
350
|
-
*
|
|
351
|
-
* The membership, wording and failure rules live in `tool-run.ts`, shared with
|
|
352
|
-
* the height calculator. A failure never breaks the run — fragmenting it around
|
|
353
|
-
* one would hide the failure in a longer list rather than surface it — but only
|
|
354
|
-
* the run's **last** call colours it, because that is the run's outcome and an
|
|
355
|
-
* outcome is what a collapsed row can honestly claim (see `runFailed`).
|
|
356
|
-
*/
|
|
357
238
|
export function ToolRunRow({ items }: { items: ToolCallItem[] }) {
|
|
358
239
|
const [open, setOpen] = useState(false)
|
|
359
240
|
const reveal = useRevealOnOpen(open)
|
|
@@ -367,13 +248,7 @@ export function ToolRunRow({ items }: { items: ToolCallItem[] }) {
|
|
|
367
248
|
return (
|
|
368
249
|
<div ref={reveal} className={open ? 'term-open' : undefined}>
|
|
369
250
|
<Pressable onPress={() => setOpen((v) => !v)} expanded={open}>
|
|
370
|
-
{
|
|
371
|
-
would give it the weight of something the model said. While one is
|
|
372
|
-
running the pulse earns the gutter — that much is news. */}
|
|
373
|
-
<Row
|
|
374
|
-
glyph={busy ? pulse : undefined}
|
|
375
|
-
glyphTone={busy ? 'mark' : undefined}
|
|
376
|
-
tone={failed ? 'red' : 'dim'}>
|
|
251
|
+
<Row glyph={busy ? pulse : undefined} glyphTone={busy ? 'mark' : undefined} tone={failed ? 'red' : 'dim'}>
|
|
377
252
|
{runSummary(items, busy)}
|
|
378
253
|
</Row>
|
|
379
254
|
</Pressable>
|
|
@@ -388,21 +263,14 @@ export function ToolRunRow({ items }: { items: ToolCallItem[] }) {
|
|
|
388
263
|
)
|
|
389
264
|
}
|
|
390
265
|
|
|
391
|
-
export function TurnResultRow({
|
|
392
|
-
item,
|
|
393
|
-
}: {
|
|
394
|
-
item: Extract<TranscriptItem, { kind: 'turn_result' }>
|
|
395
|
-
}) {
|
|
266
|
+
export function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind: 'turn_result' }> }) {
|
|
396
267
|
return (
|
|
397
268
|
<div>
|
|
398
269
|
<Row tone={item.isError ? 'red' : 'faint'}>
|
|
399
|
-
{item.isError ? item.subtype : 'done'} · {formatDuration(item.durationMs)} ·{
|
|
400
|
-
{formatCost(item.totalCostUsd)}
|
|
270
|
+
{item.isError ? item.subtype : 'done'} · {formatDuration(item.durationMs)} · {formatCost(item.totalCostUsd)}
|
|
401
271
|
</Row>
|
|
402
|
-
{/* A failed turn's reasons are the whole point of the row — dropping them
|
|
403
|
-
leaves "error_during_execution" and nothing to act on. */}
|
|
404
272
|
{item.errors?.map((message, index) => (
|
|
405
|
-
<Row key={index} tone=
|
|
273
|
+
<Row key={index} tone="red">
|
|
406
274
|
{message}
|
|
407
275
|
</Row>
|
|
408
276
|
))}
|
|
@@ -413,72 +281,49 @@ export function TurnResultRow({
|
|
|
413
281
|
export function NoticeRow({ item }: { item: Extract<TranscriptItem, { kind: 'notice' }> }) {
|
|
414
282
|
const error = item.level === 'error'
|
|
415
283
|
return (
|
|
416
|
-
<Row glyph=
|
|
284
|
+
<Row glyph="!" glyphTone={error ? 'red' : 'yellow'} tone={error ? 'red' : 'dim'}>
|
|
417
285
|
{item.text}
|
|
418
286
|
</Row>
|
|
419
287
|
)
|
|
420
288
|
}
|
|
421
289
|
|
|
422
|
-
export function FileRow({
|
|
423
|
-
item,
|
|
424
|
-
href,
|
|
425
|
-
}: {
|
|
426
|
-
item: Extract<TranscriptItem, { kind: 'file_delivered' }>
|
|
427
|
-
href?: string
|
|
428
|
-
}) {
|
|
290
|
+
export function FileRow({ item, href }: { item: Extract<TranscriptItem, { kind: 'file_delivered' }>; href?: string }) {
|
|
429
291
|
return (
|
|
430
|
-
<Row glyph=
|
|
292
|
+
<Row glyph="⤓" glyphTone="blue" tone="dim">
|
|
431
293
|
{href ? (
|
|
432
|
-
<a className=
|
|
294
|
+
<a className="term-link" data-tone="blue" href={href} download>
|
|
433
295
|
{item.path}
|
|
434
296
|
</a>
|
|
435
297
|
) : (
|
|
436
|
-
<Ink tone=
|
|
298
|
+
<Ink tone="blue">{item.path}</Ink>
|
|
437
299
|
)}
|
|
438
|
-
<Ink tone=
|
|
439
|
-
{item.description ? <Ink tone=
|
|
300
|
+
<Ink tone="faint"> · {formatBytes(item.bytes)}</Ink>
|
|
301
|
+
{item.description ? <Ink tone="faint"> · {item.description}</Ink> : null}
|
|
440
302
|
</Row>
|
|
441
303
|
)
|
|
442
304
|
}
|
|
443
305
|
|
|
444
|
-
/** A once-a-second clock, running only while `on`. */
|
|
445
306
|
export function useTicker(on: boolean): number {
|
|
446
307
|
const [now, setNow] = useState(() => Date.now())
|
|
447
308
|
useEffect(() => {
|
|
448
|
-
if (!on)
|
|
309
|
+
if (!on) {
|
|
310
|
+
return
|
|
311
|
+
}
|
|
449
312
|
const timer = setInterval(() => setNow(Date.now()), 1000)
|
|
450
313
|
return () => clearInterval(timer)
|
|
451
314
|
}, [on])
|
|
452
315
|
return now
|
|
453
316
|
}
|
|
454
317
|
|
|
455
|
-
|
|
456
|
-
* The working line: the mark's pulse, the word, and the run's readings — the
|
|
457
|
-
* CLI's own status line, which is a *row of the transcript* rather than a
|
|
458
|
-
* spinner floating over it.
|
|
459
|
-
*/
|
|
460
|
-
export function WorkingRow({
|
|
461
|
-
label,
|
|
462
|
-
startedAt,
|
|
463
|
-
tokens,
|
|
464
|
-
}: {
|
|
465
|
-
label: string
|
|
466
|
-
startedAt?: number
|
|
467
|
-
tokens?: number
|
|
468
|
-
}) {
|
|
318
|
+
export function WorkingRow({ label, startedAt, tokens }: { label: string; startedAt?: number; tokens?: number }) {
|
|
469
319
|
const pulse = usePulse(true)
|
|
470
|
-
// The row owns its clock rather than taking `now` from above, because it is
|
|
471
|
-
// mounted only while a turn is in flight: the ticking starts and stops with
|
|
472
|
-
// the thing being timed, and an idle transcript runs no interval at all.
|
|
473
320
|
const now = useTicker(startedAt !== undefined)
|
|
474
321
|
const elapsed = startedAt === undefined ? undefined : formatDuration(now - startedAt)
|
|
475
|
-
const readings = [elapsed, tokens ? `↓ ${(tokens / 1000).toFixed(1)}k tokens` : undefined].filter(
|
|
476
|
-
Boolean,
|
|
477
|
-
)
|
|
322
|
+
const readings = [elapsed, tokens ? `↓ ${(tokens / 1000).toFixed(1)}k tokens` : undefined].filter(Boolean)
|
|
478
323
|
return (
|
|
479
|
-
<Row glyph={pulse} glyphTone=
|
|
324
|
+
<Row glyph={pulse} glyphTone="mark" tone="mark">
|
|
480
325
|
{label}
|
|
481
|
-
{readings.length ? <Ink tone=
|
|
326
|
+
{readings.length ? <Ink tone="faint"> ({readings.join(' · ')})</Ink> : null}
|
|
482
327
|
</Row>
|
|
483
328
|
)
|
|
484
329
|
}
|