@workerdeck/ui 0.15.0 → 0.16.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 +53 -0
- package/build/{SessionPanel-J2U8v88q.d.mts → SessionPanel-B9CHoq8x.d.mts} +158 -21
- package/build/{SessionPanel-DI1NO4l8.mjs → SessionPanel-DII9MmQ8.mjs} +4254 -1661
- package/build/SessionPanel-DII9MmQ8.mjs.map +1 -0
- package/build/{format-ljc3lKpA.d.mts → format-DfI_je9S.d.mts} +1 -1
- package/build/format.d.mts +39 -4
- package/build/format.mjs +2 -118
- package/build/index.d.mts +442 -45
- package/build/index.mjs +105 -2
- package/build/index.mjs.map +1 -1
- package/build/status-Ydzi7n6j.mjs +143 -0
- package/build/status-Ydzi7n6j.mjs.map +1 -0
- package/build/workspace.d.mts +9 -1
- package/build/workspace.mjs +108 -5
- package/build/workspace.mjs.map +1 -1
- package/package.json +14 -7
- package/src/components/agent/Composer.tsx +189 -89
- package/src/components/agent/Conversation.tsx +12 -12
- package/src/components/agent/FileCard.tsx +0 -26
- package/src/components/agent/FileTree.tsx +9 -8
- package/src/components/agent/Loader.tsx +22 -72
- package/src/components/agent/Message.tsx +11 -46
- package/src/components/agent/PermissionPrompt.tsx +0 -92
- package/src/components/agent/QuestionPrompt.tsx +0 -122
- package/src/components/agent/Reasoning.tsx +5 -19
- package/src/components/agent/Response.tsx +1 -132
- package/src/components/agent/SessionPanel.tsx +224 -28
- package/src/components/agent/SessionWorkspace.tsx +29 -0
- package/src/components/agent/StatusBar.tsx +20 -4
- package/src/components/agent/ToolCallCard.tsx +17 -111
- package/src/components/agent/Transcript.tsx +710 -203
- package/src/components/agent/UsageDialog.tsx +20 -106
- package/src/components/agent/UsageMeters.tsx +133 -0
- package/src/components/agent/pulse.tsx +3 -2
- package/src/components/agent/transcript-rows.ts +82 -0
- package/src/components/agent/transcript-variant.tsx +29 -51
- package/src/components/agent/use-height-epoch.ts +60 -0
- package/src/components/agent/use-path-links.ts +147 -0
- package/src/components/agent/use-transcript-jumps.ts +190 -0
- package/src/components/prompt-area/cursor-helpers.ts +65 -0
- package/src/components/prompt-area/use-prompt-area.ts +16 -10
- package/src/components/terminal/PermissionPrompt.tsx +119 -0
- package/src/components/terminal/QuestionPrompt.tsx +322 -0
- package/src/components/terminal/StatusLine.tsx +159 -0
- package/src/components/terminal/TerminalTranscript.tsx +147 -0
- package/src/components/terminal/affordances.tsx +118 -0
- package/src/components/terminal/diff.tsx +130 -0
- package/src/components/terminal/height.ts +727 -0
- package/src/components/terminal/items.tsx +449 -0
- package/src/components/terminal/markdown.tsx +191 -0
- package/src/components/terminal/press.tsx +120 -0
- package/src/components/terminal/prompt.tsx +343 -0
- package/src/components/terminal/result-preview.ts +72 -0
- package/src/components/terminal/row.tsx +132 -0
- package/src/components/terminal/scrubber.tsx +663 -0
- package/src/components/terminal/surface.tsx +80 -0
- package/src/components/terminal/tool-run.ts +91 -0
- package/src/index.ts +34 -0
- package/src/lib/status.ts +59 -3
- package/src/lib/tool-icon.ts +14 -0
- package/src/styles/terminal.css +1011 -0
- package/src/styles/theme.css +41 -0
- package/build/SessionPanel-DI1NO4l8.mjs.map +0 -1
- package/build/format.mjs.map +0 -1
- package/src/components/agent/line-prompt.tsx +0 -249
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createContext, useContext, useState, type ReactNode } from 'react'
|
|
2
|
+
import { cn } from '../../lib/utils.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The things a terminal cannot do, and this can.
|
|
6
|
+
*
|
|
7
|
+
* The theme's whole discipline is that it renders like a CLI — but it is not a
|
|
8
|
+
* CLI, and refusing every affordance a pointer makes possible would be cosplay
|
|
9
|
+
* rather than design. A terminal cannot highlight the row under your cursor and
|
|
10
|
+
* cannot put a copy button on a block of output; a web view can do both for free
|
|
11
|
+
* and they are genuinely useful.
|
|
12
|
+
*
|
|
13
|
+
* The rule that keeps this honest is that **each one costs no layout**. A hover
|
|
14
|
+
* fill is a background. An action button is an overlay at the row's right edge,
|
|
15
|
+
* one line tall, absolutely positioned so it displaces nothing. Turn them all
|
|
16
|
+
* off and every glyph is on exactly the same cell it was on — which is what
|
|
17
|
+
* makes `off` a real option rather than a degraded mode, and why it is the mode
|
|
18
|
+
* a host projecting to a real terminal would choose.
|
|
19
|
+
*
|
|
20
|
+
* Off by nothing in particular: both default **on**, because the surface these
|
|
21
|
+
* render on is a browser and pretending otherwise helps nobody. A host that
|
|
22
|
+
* wants the pure article passes `affordances={false}`.
|
|
23
|
+
*/
|
|
24
|
+
export type TerminalAffordances = {
|
|
25
|
+
/** Fill the row under the pointer, on anything pressable. */
|
|
26
|
+
hover?: boolean
|
|
27
|
+
/** Reveal a row's actions (copy, and whatever a row adds) on hover or focus. */
|
|
28
|
+
actions?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DEFAULTS: Required<TerminalAffordances> = { hover: true, actions: true }
|
|
32
|
+
|
|
33
|
+
const AffordanceContext = createContext<Required<TerminalAffordances>>(DEFAULTS)
|
|
34
|
+
|
|
35
|
+
export function useAffordances(): Required<TerminalAffordances> {
|
|
36
|
+
return useContext(AffordanceContext)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Resolve the surface's prop — `false` means none, `true`/absent means all. */
|
|
40
|
+
export function resolveAffordances(
|
|
41
|
+
value: TerminalAffordances | boolean | undefined,
|
|
42
|
+
): Required<TerminalAffordances> {
|
|
43
|
+
if (value === false) return { hover: false, actions: false }
|
|
44
|
+
if (value === true || value === undefined) return DEFAULTS
|
|
45
|
+
return { ...DEFAULTS, ...value }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function AffordanceProvider({
|
|
49
|
+
value,
|
|
50
|
+
children,
|
|
51
|
+
}: {
|
|
52
|
+
value: Required<TerminalAffordances>
|
|
53
|
+
children: ReactNode
|
|
54
|
+
}) {
|
|
55
|
+
return <AffordanceContext.Provider value={value}>{children}</AffordanceContext.Provider>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A block that reveals its actions on hover.
|
|
60
|
+
*
|
|
61
|
+
* Wraps rather than decorates because the actions belong to the *block* — a
|
|
62
|
+
* message is many rows and its copy button belongs at the top right of all of
|
|
63
|
+
* them, not on whichever row the pointer happens to be over.
|
|
64
|
+
*/
|
|
65
|
+
export function WithActions({
|
|
66
|
+
actions,
|
|
67
|
+
children,
|
|
68
|
+
className,
|
|
69
|
+
}: {
|
|
70
|
+
actions: ReactNode
|
|
71
|
+
children: ReactNode
|
|
72
|
+
className?: string
|
|
73
|
+
}) {
|
|
74
|
+
const { actions: enabled } = useAffordances()
|
|
75
|
+
if (!enabled) return <>{children}</>
|
|
76
|
+
return (
|
|
77
|
+
<div className={cn('term-hoverable', className)}>
|
|
78
|
+
{children}
|
|
79
|
+
<div className='term-actions'>{actions}</div>
|
|
80
|
+
</div>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Copy, as a character.
|
|
86
|
+
*
|
|
87
|
+
* `⧉` and `✓` rather than an SVG for the same reason the rest of the theme uses
|
|
88
|
+
* glyphs: an icon drawn at some other size sits between the cells everything
|
|
89
|
+
* else is on, and reads as a button borrowed from another application. The tick
|
|
90
|
+
* replaces the glyph in place, so the confirmation costs no width either.
|
|
91
|
+
*/
|
|
92
|
+
export function CopyAction({ text, label = 'Copy' }: { text: string; label?: string }) {
|
|
93
|
+
const [copied, setCopied] = useState(false)
|
|
94
|
+
return (
|
|
95
|
+
<button
|
|
96
|
+
type='button'
|
|
97
|
+
className='term-action'
|
|
98
|
+
title={label}
|
|
99
|
+
aria-label={label}
|
|
100
|
+
onClick={(event) => {
|
|
101
|
+
// The row underneath is usually pressable (a tool call expands); copying
|
|
102
|
+
// is not expanding.
|
|
103
|
+
event.stopPropagation()
|
|
104
|
+
void navigator.clipboard
|
|
105
|
+
?.writeText(text)
|
|
106
|
+
.then(() => {
|
|
107
|
+
setCopied(true)
|
|
108
|
+
setTimeout(() => setCopied(false), 1200)
|
|
109
|
+
})
|
|
110
|
+
// No toast, no error row: a refused clipboard (an insecure origin, a
|
|
111
|
+
// denied permission) is the host's business, and a failed copy that
|
|
112
|
+
// says nothing is better than a transcript that grows an error.
|
|
113
|
+
.catch(() => {})
|
|
114
|
+
}}>
|
|
115
|
+
{copied ? '✓' : '⧉'}
|
|
116
|
+
</button>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { FilePatch, PatchHunk } from '@workerdeck/protocol'
|
|
2
|
+
import { Row } from './row.tsx'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A file edit, drawn the way the CLI draws it: a right-aligned line-number
|
|
6
|
+
* column, a one-column `+`/`-` marker, and the line — with added and removed
|
|
7
|
+
* rows carrying a full-bleed wash.
|
|
8
|
+
*
|
|
9
|
+
* The line numbers are the engine's own, off the wire (protocol's
|
|
10
|
+
* {@link FilePatch}). Nothing here computes one: this component has never seen
|
|
11
|
+
* the file, and a number it invented would be worse than no number at all —
|
|
12
|
+
* it would look authoritative and send the reader to the wrong line.
|
|
13
|
+
*
|
|
14
|
+
* The whole row — number, marker and text — is one {@link Row}, with the number
|
|
15
|
+
* and marker as its gutter. That is what keeps a wrapped line hanging under the
|
|
16
|
+
* text rather than under the numbers, and it is why the gutter width is computed
|
|
17
|
+
* rather than fixed: a four-digit file and a two-digit one both put their first
|
|
18
|
+
* character of code on a whole column.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Which side of the edit a body line belongs to, from its unified-diff prefix. */
|
|
22
|
+
type LineKind = 'context' | 'add' | 'remove'
|
|
23
|
+
|
|
24
|
+
const kindOf = (line: string): LineKind =>
|
|
25
|
+
line.startsWith('+') ? 'add' : line.startsWith('-') ? 'remove' : 'context'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The rows of one hunk, each with the line number it has *in the file*.
|
|
29
|
+
*
|
|
30
|
+
* Two counters, because a diff is two files interleaved: an added line has a
|
|
31
|
+
* number only in the new file, a removed line only in the old one, and context
|
|
32
|
+
* advances both. Showing the new number for adds and the old number for removes
|
|
33
|
+
* is what makes the column mean "where do I find this line", which is the only
|
|
34
|
+
* reason to print it.
|
|
35
|
+
*/
|
|
36
|
+
function hunkRows(hunk: PatchHunk): { kind: LineKind; number: number; text: string }[] {
|
|
37
|
+
let oldLine = hunk.oldStart
|
|
38
|
+
let newLine = hunk.newStart
|
|
39
|
+
const rows = []
|
|
40
|
+
for (const line of hunk.lines) {
|
|
41
|
+
const kind = kindOf(line)
|
|
42
|
+
// The prefix is diff syntax, not content — the marker column says it now.
|
|
43
|
+
const text = line.slice(1)
|
|
44
|
+
if (kind === 'add') rows.push({ kind, number: newLine++, text })
|
|
45
|
+
else if (kind === 'remove') rows.push({ kind, number: oldLine++, text })
|
|
46
|
+
else {
|
|
47
|
+
rows.push({ kind, number: newLine, text })
|
|
48
|
+
oldLine++
|
|
49
|
+
newLine++
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return rows
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const MARKER = { context: ' ', add: '+', remove: '-' } as const
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A patch for a change that has **not happened yet** — an approval prompt's
|
|
59
|
+
* `Edit`, where the input names the old and new text but no line numbers exist
|
|
60
|
+
* because the edit has not been applied and this client has never read the file.
|
|
61
|
+
*
|
|
62
|
+
* `newStart: 0` is how it says so, and {@link TerminalDiff} reads that back: a
|
|
63
|
+
* diff whose hunks all start at zero renders without a number column rather than
|
|
64
|
+
* printing a column of zeroes or inventing an offset.
|
|
65
|
+
*/
|
|
66
|
+
export function previewPatch(input: unknown): FilePatch | undefined {
|
|
67
|
+
const edit = input as { file_path?: unknown; old_string?: unknown; new_string?: unknown } | null
|
|
68
|
+
const before = typeof edit?.old_string === 'string' ? edit.old_string : undefined
|
|
69
|
+
const after = typeof edit?.new_string === 'string' ? edit.new_string : undefined
|
|
70
|
+
if (before === undefined && after === undefined) return undefined
|
|
71
|
+
const lines = [
|
|
72
|
+
...(before ? before.split('\n').map((line) => `-${line}`) : []),
|
|
73
|
+
...(after ? after.split('\n').map((line) => `+${line}`) : []),
|
|
74
|
+
]
|
|
75
|
+
if (lines.length === 0) return undefined
|
|
76
|
+
return {
|
|
77
|
+
...(typeof edit?.file_path === 'string' && { path: edit.file_path }),
|
|
78
|
+
hunks: [{ oldStart: 0, oldLines: 0, newStart: 0, newLines: 0, lines }],
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function TerminalDiff({ patch }: { patch: FilePatch }) {
|
|
83
|
+
const rows = patch.hunks.map(hunkRows)
|
|
84
|
+
// Numbers only when the engine gave any (see `previewPatch`): a column of
|
|
85
|
+
// zeroes would look like line 0 of the file, which is a lie about where to
|
|
86
|
+
// look — and a diff with no numbers is honest about having none.
|
|
87
|
+
const numbered = patch.hunks.some((hunk) => hunk.newStart > 0)
|
|
88
|
+
// Wide enough for the largest number any row will print, so the marker and the
|
|
89
|
+
// code start on the same column throughout — including across a hunk boundary,
|
|
90
|
+
// where the numbers jump.
|
|
91
|
+
const width = numbered ? String(Math.max(...rows.flat().map((row) => row.number), 1)).length : 0
|
|
92
|
+
// number + space + marker + space (or just the marker and a space)
|
|
93
|
+
const columns = numbered ? width + 3 : 2
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div className='term-diff'>
|
|
97
|
+
{rows.map((hunk, index) => (
|
|
98
|
+
<div key={index}>
|
|
99
|
+
{/* Hunks are not adjacent in the file, and a blank line would say they
|
|
100
|
+
were merely a paragraph apart. The CLI's separator is the honest
|
|
101
|
+
one: a row that says lines were skipped. */}
|
|
102
|
+
{index > 0 ? (
|
|
103
|
+
<Row columns={columns} glyph={' '.repeat(width) + ' ⋮'} tone='faint' />
|
|
104
|
+
) : null}
|
|
105
|
+
{hunk.map((row, line) => (
|
|
106
|
+
<Row
|
|
107
|
+
key={line}
|
|
108
|
+
columns={columns}
|
|
109
|
+
data-diff={row.kind}
|
|
110
|
+
glyph={
|
|
111
|
+
numbered
|
|
112
|
+
? `${String(row.number).padStart(width)} ${MARKER[row.kind]}`
|
|
113
|
+
: MARKER[row.kind]
|
|
114
|
+
}
|
|
115
|
+
// `pre-wrap` on the body already keeps the code's own indentation;
|
|
116
|
+
// an empty line still has to occupy its row, hence the space.
|
|
117
|
+
>
|
|
118
|
+
{row.text || ' '}
|
|
119
|
+
</Row>
|
|
120
|
+
))}
|
|
121
|
+
</div>
|
|
122
|
+
))}
|
|
123
|
+
{patch.truncated ? (
|
|
124
|
+
<Row columns={columns} tone='faint'>
|
|
125
|
+
… diff truncated
|
|
126
|
+
</Row>
|
|
127
|
+
) : null}
|
|
128
|
+
</div>
|
|
129
|
+
)
|
|
130
|
+
}
|