@workerdeck/ui 2.13.0 → 2.15.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-CJwp5B3q.d.mts → SessionPanel-BVKqPs_3.d.mts} +9 -3
- package/build/index.d.mts +84 -6
- package/build/index.mjs +83 -37
- package/build/index.mjs.map +1 -1
- package/build/scoped.css +24 -3
- package/build/{use-path-links-CepFsMdW.mjs → use-path-links-BgWXmfAS.mjs} +849 -236
- package/build/use-path-links-BgWXmfAS.mjs.map +1 -0
- package/build/workspace.d.mts +19 -4
- package/build/workspace.mjs +7 -4
- package/build/workspace.mjs.map +1 -1
- package/package.json +16 -6
- package/src/components/agent/Composer.tsx +18 -13
- package/src/components/agent/SessionBrowser.tsx +24 -1
- package/src/components/agent/SessionItem.tsx +13 -3
- package/src/components/agent/SessionPanel.tsx +264 -211
- package/src/components/agent/SessionSteps.tsx +78 -27
- package/src/components/agent/SessionWorkspace.tsx +10 -1
- package/src/components/agent/ShellStrip.tsx +71 -0
- package/src/components/agent/ShellTerminal.tsx +170 -0
- package/src/components/agent/SubagentToggle.tsx +7 -1
- package/src/components/agent/TranscriptItemView.tsx +74 -1
- package/src/components/agent/scrubber-marks.ts +9 -1
- package/src/components/agent/shell-actions.tsx +25 -0
- package/src/components/agent/use-shell-frame.ts +111 -0
- package/src/components/terminal/TerminalTranscript.tsx +4 -0
- package/src/components/terminal/affordances.tsx +37 -0
- package/src/components/terminal/height.ts +14 -0
- package/src/components/terminal/items.tsx +72 -3
- package/src/components/terminal/press.tsx +5 -3
- package/src/components/terminal/scrubber.tsx +9 -1
- package/src/components/terminal/shell-row.ts +106 -0
- package/src/index.ts +12 -1
- package/src/styles/terminal.css +11 -0
- package/src/styles/theme.css +3 -0
- package/src/workspace.ts +1 -0
- package/build/use-path-links-CepFsMdW.mjs.map +0 -1
|
@@ -1,24 +1,41 @@
|
|
|
1
1
|
import { ArrowRight, Check, CircleAlert } from 'lucide-react'
|
|
2
|
-
import { isAgentRecord, subagentLabel, visibleSubagents } from '@workerdeck/protocol'
|
|
3
|
-
import type { SessionInfo, SubagentDisplay, SubagentInfo } from '@workerdeck/protocol'
|
|
2
|
+
import { isAgentRecord, promotedShells, subagentLabel, visibleSubagents } from '@workerdeck/protocol'
|
|
3
|
+
import type { SessionInfo, ShellInfo, SubagentDisplay, SubagentInfo } from '@workerdeck/protocol'
|
|
4
4
|
import { Spinner } from '../ui/Spinner.tsx'
|
|
5
5
|
import { cn } from '../../lib/utils.ts'
|
|
6
|
+
import { SHELL_GLYPH, SHELL_KILL_GLYPH, shellInfoFailed, shellInfoLabel, shellInfoStatusText, shellTitle } from '../terminal/shell-row.ts'
|
|
7
|
+
|
|
8
|
+
export type StepKind = 'agent' | 'shell'
|
|
6
9
|
|
|
7
10
|
export type Step = {
|
|
8
11
|
key: string
|
|
12
|
+
kind: StepKind
|
|
9
13
|
label: string
|
|
10
14
|
noun: string
|
|
11
15
|
state: 'done' | 'running' | 'failed'
|
|
12
16
|
detail?: string
|
|
13
17
|
title: string
|
|
14
18
|
onSelect: () => void
|
|
19
|
+
onKill?: () => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ShellStepOptions = {
|
|
23
|
+
now: number
|
|
24
|
+
onSelect: (shellId: string) => void
|
|
25
|
+
onKill?: (shellId: string) => void
|
|
15
26
|
}
|
|
16
27
|
|
|
17
|
-
export function sessionSteps(
|
|
18
|
-
|
|
28
|
+
export function sessionSteps(
|
|
29
|
+
info: SessionInfo,
|
|
30
|
+
onSelect: (toolUseId: string) => void,
|
|
31
|
+
show: SubagentDisplay = 'all',
|
|
32
|
+
shells?: ShellStepOptions,
|
|
33
|
+
): Step[] {
|
|
34
|
+
const agents: Step[] = visibleSubagents(info, show)
|
|
19
35
|
.filter(isAgentRecord)
|
|
20
36
|
.map((sub) => ({
|
|
21
37
|
key: sub.toolUseId,
|
|
38
|
+
kind: 'agent',
|
|
22
39
|
label: subagentLabel(sub),
|
|
23
40
|
noun: 'agent',
|
|
24
41
|
state: stepState(sub.status),
|
|
@@ -26,6 +43,26 @@ export function sessionSteps(info: SessionInfo, onSelect: (toolUseId: string) =>
|
|
|
26
43
|
title: `${subagentLabel(sub)} · ${sub.toolCount} tool${sub.toolCount === 1 ? '' : 's'}`,
|
|
27
44
|
onSelect: () => onSelect(sub.toolUseId),
|
|
28
45
|
}))
|
|
46
|
+
if (shells === undefined) {
|
|
47
|
+
return agents
|
|
48
|
+
}
|
|
49
|
+
return [...agents, ...promotedShells(info, shells.now).map((shell) => shellStep(shell, shells))]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function shellStep(shell: ShellInfo, options: ShellStepOptions): Step {
|
|
53
|
+
const status = shellInfoStatusText(shell)
|
|
54
|
+
const running = shell.status === 'running'
|
|
55
|
+
return {
|
|
56
|
+
key: shell.id,
|
|
57
|
+
kind: 'shell',
|
|
58
|
+
label: shellInfoLabel(shell),
|
|
59
|
+
noun: 'shell',
|
|
60
|
+
state: shellInfoFailed(shell) ? 'failed' : running ? 'running' : 'done',
|
|
61
|
+
detail: running ? undefined : status,
|
|
62
|
+
title: `${shellTitle(shell)} · ${status}`,
|
|
63
|
+
onSelect: () => options.onSelect(shell.id),
|
|
64
|
+
onKill: running && options.onKill ? () => options.onKill?.(shell.id) : undefined,
|
|
65
|
+
}
|
|
29
66
|
}
|
|
30
67
|
|
|
31
68
|
function stepState(status: SubagentInfo['status']): Step['state'] {
|
|
@@ -43,33 +80,47 @@ function stepState(status: SubagentInfo['status']): Step['state'] {
|
|
|
43
80
|
}
|
|
44
81
|
|
|
45
82
|
export function StepRow({ step, active = false, onSelect }: { step: Step; active?: boolean; onSelect: () => void }) {
|
|
46
|
-
const body = step.state === 'failed' ? 'text-danger' : 'text-success'
|
|
83
|
+
const body = step.state === 'failed' ? 'text-danger' : step.kind === 'shell' ? 'text-[var(--wd-shell-accent)]' : 'text-success'
|
|
47
84
|
return (
|
|
48
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
e
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
85
|
+
<div className={cn('flex w-full items-center rounded-[4px] pr-1.5', active ? 'bg-row-selected' : 'hover:bg-row-active', body)}>
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
title={step.title}
|
|
89
|
+
aria-current={active || undefined}
|
|
90
|
+
onClick={(e) => {
|
|
91
|
+
e.stopPropagation()
|
|
92
|
+
onSelect()
|
|
93
|
+
}}
|
|
94
|
+
className="flex min-w-0 flex-1 items-center gap-1.5 py-1 pr-1 pl-3.5 text-left text-micro outline-none"
|
|
95
|
+
>
|
|
96
|
+
<StepIcon step={step} />
|
|
97
|
+
<span className="min-w-0 flex-1 truncate">{step.label}</span>
|
|
98
|
+
{step.detail ? <span className="shrink-0 tabular-nums text-fg-4">{step.detail}</span> : null}
|
|
99
|
+
<ArrowRight className="size-3.5 shrink-0 text-fg-4" />
|
|
100
|
+
</button>
|
|
101
|
+
{step.onKill ? (
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
aria-label={`Kill ${step.label}`}
|
|
105
|
+
title="Kill this shell"
|
|
106
|
+
onClick={(e) => {
|
|
107
|
+
e.stopPropagation()
|
|
108
|
+
step.onKill?.()
|
|
109
|
+
}}
|
|
110
|
+
className="shrink-0 px-1 text-micro leading-none text-fg-4 outline-none hover:text-danger"
|
|
111
|
+
>
|
|
112
|
+
{SHELL_KILL_GLYPH}
|
|
113
|
+
</button>
|
|
114
|
+
) : null}
|
|
115
|
+
</div>
|
|
68
116
|
)
|
|
69
117
|
}
|
|
70
118
|
|
|
71
|
-
function StepIcon({
|
|
72
|
-
|
|
119
|
+
function StepIcon({ step }: { step: Step }) {
|
|
120
|
+
if (step.kind === 'shell') {
|
|
121
|
+
return <span className="w-[11px] shrink-0 text-center leading-none">{SHELL_GLYPH}</span>
|
|
122
|
+
}
|
|
123
|
+
switch (step.state) {
|
|
73
124
|
case 'running': {
|
|
74
125
|
return <Spinner className="size-[11px] shrink-0" />
|
|
75
126
|
}
|
|
@@ -27,6 +27,9 @@ export interface SessionWorkspaceProps {
|
|
|
27
27
|
reveal?: SessionPanelProps['reveal']
|
|
28
28
|
subagents?: SessionPanelProps['subagents']
|
|
29
29
|
onSubagentChange?: SessionPanelProps['onSubagentChange']
|
|
30
|
+
openShell?: SessionPanelProps['openShell']
|
|
31
|
+
shells?: SessionPanelProps['shells']
|
|
32
|
+
onShellChange?: SessionPanelProps['onShellChange']
|
|
30
33
|
statusPlacement?: SessionPanelProps['statusPlacement']
|
|
31
34
|
controlsSurface?: SessionPanelProps['controlsSurface']
|
|
32
35
|
fontSize?: SessionPanelProps['fontSize']
|
|
@@ -61,6 +64,9 @@ export function SessionWorkspace({
|
|
|
61
64
|
reveal,
|
|
62
65
|
subagents,
|
|
63
66
|
onSubagentChange,
|
|
67
|
+
openShell,
|
|
68
|
+
shells,
|
|
69
|
+
onShellChange,
|
|
64
70
|
statusPlacement,
|
|
65
71
|
controlsSurface,
|
|
66
72
|
fontSize,
|
|
@@ -132,7 +138,7 @@ export function SessionWorkspace({
|
|
|
132
138
|
container: column,
|
|
133
139
|
onOpen: openPath,
|
|
134
140
|
enabled: tree.available,
|
|
135
|
-
ignore: '.monaco-editor',
|
|
141
|
+
ignore: '.monaco-editor, .xterm',
|
|
136
142
|
})
|
|
137
143
|
const editorMax = Math.max(EDITOR_MIN, columnHeight - AGENT_MIN)
|
|
138
144
|
|
|
@@ -221,6 +227,9 @@ export function SessionWorkspace({
|
|
|
221
227
|
reveal={reveal}
|
|
222
228
|
subagents={subagents}
|
|
223
229
|
onSubagentChange={onSubagentChange}
|
|
230
|
+
openShell={openShell}
|
|
231
|
+
shells={shells}
|
|
232
|
+
onShellChange={onShellChange}
|
|
224
233
|
controlsSurface={controlsSurface}
|
|
225
234
|
fontSize={fontSize}
|
|
226
235
|
onLinkClick={onLinkClick}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { ArrowLeft, X } from 'lucide-react'
|
|
2
|
+
import type { ShellInfo } from '@workerdeck/protocol'
|
|
3
|
+
import { cn } from '../../lib/utils.ts'
|
|
4
|
+
import { Button } from '../ui/Button.tsx'
|
|
5
|
+
import { Ink, Row } from '../terminal/row.tsx'
|
|
6
|
+
import { TerminalSurface } from '../terminal/surface.tsx'
|
|
7
|
+
import { shellInfoFailed, shellInfoStatusText, shellTitle } from '../terminal/shell-row.ts'
|
|
8
|
+
import { KillShellAction, WithActions } from '../terminal/affordances.tsx'
|
|
9
|
+
|
|
10
|
+
export interface ShellStripProps {
|
|
11
|
+
shell: ShellInfo | undefined
|
|
12
|
+
label: string
|
|
13
|
+
cols?: number
|
|
14
|
+
onBack: () => void
|
|
15
|
+
onKill?: () => void
|
|
16
|
+
terminal: boolean
|
|
17
|
+
fontSize?: number
|
|
18
|
+
lineHeight?: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ShellStrip({ shell, label, cols, onBack, onKill, terminal, fontSize, lineHeight }: ShellStripProps) {
|
|
22
|
+
const running = shell?.status === 'running'
|
|
23
|
+
const failed = shell ? shellInfoFailed(shell) : false
|
|
24
|
+
const name = shell ? shellTitle(shell) : label
|
|
25
|
+
const status = shell ? shellInfoStatusText(shell) : undefined
|
|
26
|
+
const width = cols ?? shell?.cols
|
|
27
|
+
const detail = width === undefined ? undefined : `${width} cols`
|
|
28
|
+
|
|
29
|
+
if (terminal) {
|
|
30
|
+
return (
|
|
31
|
+
<TerminalSurface fontSize={fontSize} lineHeight={lineHeight} className="shrink-0">
|
|
32
|
+
<WithActions actions={running && onKill ? <KillShellAction onKill={onKill} /> : null}>
|
|
33
|
+
<Row glyph="←" glyphTone="dim" indent={1} tone={failed ? 'red' : 'magenta'}>
|
|
34
|
+
<button type="button" onClick={onBack} aria-label="Back to the session" className="cursor-pointer text-left">
|
|
35
|
+
{name}
|
|
36
|
+
</button>
|
|
37
|
+
{status ? <Ink tone={failed ? 'red' : running ? 'magenta' : 'dim'}> · {status}</Ink> : null}
|
|
38
|
+
{detail ? <Ink tone="faint"> · {detail}</Ink> : null}
|
|
39
|
+
</Row>
|
|
40
|
+
</WithActions>
|
|
41
|
+
</TerminalSurface>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="flex shrink-0 items-center gap-2 border-b border-border px-3 py-1.5">
|
|
47
|
+
<Button variant="ghost" size="sm" onClick={onBack} className="h-6 gap-1 px-1.5">
|
|
48
|
+
<ArrowLeft className="size-3.5" />
|
|
49
|
+
Back
|
|
50
|
+
</Button>
|
|
51
|
+
<span className={cn('min-w-0 flex-1 truncate font-mono text-body-sm', failed ? 'text-danger' : 'text-fg-2')}>{name}</span>
|
|
52
|
+
{status ? (
|
|
53
|
+
<span className={cn('shrink-0 text-label', failed ? 'text-danger' : running ? 'text-[var(--wd-shell-accent)]' : 'text-fg-3')}>
|
|
54
|
+
{status}
|
|
55
|
+
</span>
|
|
56
|
+
) : null}
|
|
57
|
+
{detail ? <span className="shrink-0 text-label text-fg-4">{detail}</span> : null}
|
|
58
|
+
{running && onKill ? (
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
aria-label="Kill this shell"
|
|
62
|
+
title="Kill this shell"
|
|
63
|
+
className="shrink-0 text-label text-fg-3 hover:text-danger"
|
|
64
|
+
onClick={onKill}
|
|
65
|
+
>
|
|
66
|
+
<X className="size-3.5" />
|
|
67
|
+
</button>
|
|
68
|
+
) : null}
|
|
69
|
+
</div>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { FitAddon } from '@xterm/addon-fit'
|
|
3
|
+
import type { ITheme, Terminal as XTerm } from '@xterm/xterm'
|
|
4
|
+
import { useShellTerminal } from '@workerdeck/react'
|
|
5
|
+
import type { SessionHandle } from '@workerdeck/client'
|
|
6
|
+
import type { ShellInfo } from '@workerdeck/protocol'
|
|
7
|
+
import { cn } from '../../lib/utils.ts'
|
|
8
|
+
import { Spinner } from '../ui/Spinner.tsx'
|
|
9
|
+
|
|
10
|
+
export interface ShellTerminalProps {
|
|
11
|
+
handle: SessionHandle | undefined
|
|
12
|
+
shellId: string | undefined
|
|
13
|
+
onEnded?: (reason: string) => void
|
|
14
|
+
onShell?: (shell: ShellInfo) => void
|
|
15
|
+
fontSize?: number
|
|
16
|
+
className?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ShellTerminal({ handle, shellId, onEnded, onShell, fontSize, className }: ShellTerminalProps) {
|
|
20
|
+
const host = useRef<HTMLDivElement>(null)
|
|
21
|
+
const term = useRef<XTerm | null>(null)
|
|
22
|
+
const fit = useRef<FitAddon | null>(null)
|
|
23
|
+
const [ready, setReady] = useState(false)
|
|
24
|
+
const attached = useRef(false)
|
|
25
|
+
const theme = useDocumentTheme()
|
|
26
|
+
const onShellRef = useRef(onShell)
|
|
27
|
+
onShellRef.current = onShell
|
|
28
|
+
|
|
29
|
+
const shell = useShellTerminal(handle, shellId, {
|
|
30
|
+
onData: (data) => term.current?.write(data),
|
|
31
|
+
onAttached: (attachment) => {
|
|
32
|
+
const instance = term.current
|
|
33
|
+
if (!instance) {
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
instance.reset()
|
|
37
|
+
if (attachment.scrollback) {
|
|
38
|
+
instance.write(attachment.scrollback)
|
|
39
|
+
}
|
|
40
|
+
// Fit on attach only: a mid-stream refit reflows a cursor-addressed screen under the writer.
|
|
41
|
+
try {
|
|
42
|
+
fit.current?.fit()
|
|
43
|
+
} catch {}
|
|
44
|
+
onShellRef.current?.(attachment.shell)
|
|
45
|
+
},
|
|
46
|
+
onEnded: (reason) => {
|
|
47
|
+
term.current?.write(`\r\n\x1b[2m[${reason}]\x1b[0m\r\n`)
|
|
48
|
+
onEnded?.(reason)
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
const shellRef = useRef(shell)
|
|
52
|
+
shellRef.current = shell
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
let disposed = false
|
|
56
|
+
void loadXterm().then(({ Terminal, FitAddon: Fit }) => {
|
|
57
|
+
if (disposed || !host.current) {
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
const instance = new Terminal({
|
|
61
|
+
allowProposedApi: true,
|
|
62
|
+
convertEol: false,
|
|
63
|
+
cursorBlink: true,
|
|
64
|
+
fontSize: fontSize ?? 12,
|
|
65
|
+
lineHeight: 1.35,
|
|
66
|
+
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
|
67
|
+
theme: xtermTheme(document.documentElement.getAttribute('data-theme')),
|
|
68
|
+
scrollback: 5000,
|
|
69
|
+
})
|
|
70
|
+
const addon = new Fit()
|
|
71
|
+
instance.loadAddon(addon)
|
|
72
|
+
instance.open(host.current)
|
|
73
|
+
addon.fit()
|
|
74
|
+
instance.onData((data) => shellRef.current.write(data))
|
|
75
|
+
instance.onResize(({ cols, rows }) => shellRef.current.resize({ cols, rows }))
|
|
76
|
+
term.current = instance
|
|
77
|
+
fit.current = addon
|
|
78
|
+
setReady(true)
|
|
79
|
+
})
|
|
80
|
+
return () => {
|
|
81
|
+
disposed = true
|
|
82
|
+
attached.current = false
|
|
83
|
+
shellRef.current.detach()
|
|
84
|
+
term.current?.dispose()
|
|
85
|
+
term.current = null
|
|
86
|
+
fit.current = null
|
|
87
|
+
setReady(false)
|
|
88
|
+
}
|
|
89
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
90
|
+
}, [])
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
attached.current = false
|
|
94
|
+
}, [shellId])
|
|
95
|
+
|
|
96
|
+
// The handle reaches this pane a render *after* it mounts, so the attach is driven by all three
|
|
97
|
+
// becoming true together, never from inside the loader callback.
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
const instance = term.current
|
|
100
|
+
if (!ready || !handle || shellId === undefined || !instance || attached.current) {
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
attached.current = true
|
|
104
|
+
shellRef.current.attach({ cols: instance.cols, rows: instance.rows })
|
|
105
|
+
}, [ready, handle, shellId])
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const instance = term.current
|
|
109
|
+
if (instance) {
|
|
110
|
+
instance.options.theme = xtermTheme(theme)
|
|
111
|
+
}
|
|
112
|
+
}, [theme])
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const node = host.current
|
|
116
|
+
if (!node || !ready) {
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
const observer = new ResizeObserver(() => {
|
|
120
|
+
try {
|
|
121
|
+
fit.current?.fit()
|
|
122
|
+
} catch {}
|
|
123
|
+
})
|
|
124
|
+
observer.observe(node)
|
|
125
|
+
return () => observer.disconnect()
|
|
126
|
+
}, [ready])
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div className={cn('relative min-h-0 flex-1 overflow-hidden px-2 py-1', className)}>
|
|
130
|
+
<div ref={host} className="h-full w-full" onClick={() => term.current?.focus()} />
|
|
131
|
+
{ready && shell.status !== 'idle' ? null : (
|
|
132
|
+
<div className="absolute inset-0 grid place-items-center">
|
|
133
|
+
<Spinner />
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function useDocumentTheme(): string | null {
|
|
141
|
+
const [theme, setTheme] = useState<string | null>(() =>
|
|
142
|
+
typeof document === 'undefined' ? null : document.documentElement.getAttribute('data-theme'),
|
|
143
|
+
)
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
const root = document.documentElement
|
|
146
|
+
const sync = () => setTheme(root.getAttribute('data-theme'))
|
|
147
|
+
sync()
|
|
148
|
+
const observer = new MutationObserver(sync)
|
|
149
|
+
observer.observe(root, { attributes: true, attributeFilter: ['data-theme'] })
|
|
150
|
+
return () => observer.disconnect()
|
|
151
|
+
}, [])
|
|
152
|
+
return theme
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function xtermTheme(theme: string | null): ITheme {
|
|
156
|
+
return theme === 'light'
|
|
157
|
+
? { background: '#00000000', foreground: '#1f2328', cursor: '#1f2328', selectionBackground: '#b9c7d980' }
|
|
158
|
+
: { background: '#00000000', foreground: '#d4d4d4', cursor: '#d4d4d4', selectionBackground: '#3a4a5f80' }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
type XtermApi = { Terminal: typeof XTerm; FitAddon: typeof FitAddon }
|
|
162
|
+
|
|
163
|
+
let xtermPromise: Promise<XtermApi> | undefined
|
|
164
|
+
function loadXterm(): Promise<XtermApi> {
|
|
165
|
+
xtermPromise ??= (async () => {
|
|
166
|
+
const [core, fitAddon] = await Promise.all([import('@xterm/xterm'), import('@xterm/addon-fit')])
|
|
167
|
+
return { Terminal: core.Terminal, FitAddon: fitAddon.FitAddon }
|
|
168
|
+
})()
|
|
169
|
+
return xtermPromise
|
|
170
|
+
}
|
|
@@ -49,7 +49,13 @@ export function SubagentToggle({ value, onChange, className }: SubagentTogglePro
|
|
|
49
49
|
<Menu>
|
|
50
50
|
<MenuTrigger
|
|
51
51
|
render={
|
|
52
|
-
<Button
|
|
52
|
+
<Button
|
|
53
|
+
variant="ghost"
|
|
54
|
+
size="icon-sm"
|
|
55
|
+
aria-label={`Sub-agents: ${meta.label}`}
|
|
56
|
+
title={meta.hint}
|
|
57
|
+
className={cn('w-9 gap-0.5', className)}
|
|
58
|
+
>
|
|
53
59
|
<SubagentDisplayIcon value={value} className="size-4" />
|
|
54
60
|
<ChevronDown className="size-2.5 text-fg-4" />
|
|
55
61
|
</Button>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { MessageAttachment } from '@workerdeck/protocol'
|
|
2
|
-
import
|
|
2
|
+
import { useEffect, useState } from 'react'
|
|
3
|
+
import type { ShellItem, TranscriptItem } from '@workerdeck/react'
|
|
3
4
|
import { cn } from '../../lib/utils.ts'
|
|
4
5
|
import { compactionText, formatCost, formatDuration, formatRelativeTime } from '../../lib/format.ts'
|
|
5
6
|
import { FileCard } from './FileCard.tsx'
|
|
@@ -11,6 +12,8 @@ import { ToolCallCard } from './ToolCallCard.tsx'
|
|
|
11
12
|
import { Row } from '../terminal/row.tsx'
|
|
12
13
|
import { TerminalItemView } from '../terminal/TerminalTranscript.tsx'
|
|
13
14
|
import { peerLabel } from '../terminal/items.tsx'
|
|
15
|
+
import { useShellActions } from './shell-actions.tsx'
|
|
16
|
+
import { shellBodyLines, shellFailed, shellFooterText, shellLabel, shellStatusText } from '../terminal/shell-row.ts'
|
|
14
17
|
|
|
15
18
|
function TurnResultRow({ item }: { item: Extract<TranscriptItem, { kind: 'turn_result' }> }) {
|
|
16
19
|
return (
|
|
@@ -107,12 +110,82 @@ export function TranscriptItemView({
|
|
|
107
110
|
case 'file_delivered': {
|
|
108
111
|
return <FileCard item={item} href={fileUrl?.(item.path)} />
|
|
109
112
|
}
|
|
113
|
+
case 'shell': {
|
|
114
|
+
return <ShellCard item={item} />
|
|
115
|
+
}
|
|
110
116
|
default: {
|
|
111
117
|
return null
|
|
112
118
|
}
|
|
113
119
|
}
|
|
114
120
|
}
|
|
115
121
|
|
|
122
|
+
function ShellCard({ item }: { item: ShellItem }) {
|
|
123
|
+
const [open, setOpen] = useState(false)
|
|
124
|
+
const actions = useShellActions()
|
|
125
|
+
const running = item.shell.status === 'running'
|
|
126
|
+
const failed = shellFailed(item)
|
|
127
|
+
const lines = shellBodyLines(item, open)
|
|
128
|
+
const footer = shellFooterText(item, open, lines.length)
|
|
129
|
+
|
|
130
|
+
const shellId = item.shell.id
|
|
131
|
+
const verify = actions.verify
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (running) {
|
|
134
|
+
void verify(shellId)
|
|
135
|
+
}
|
|
136
|
+
}, [running, shellId, verify])
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<div data-slot="shell" className="overflow-hidden rounded-md border border-border bg-surface">
|
|
140
|
+
<div className="flex items-center gap-2 px-3 py-1.5">
|
|
141
|
+
<span className="font-mono text-label text-[var(--wd-shell-accent)]">$</span>
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
aria-expanded={open}
|
|
145
|
+
className="min-w-0 flex-1 truncate text-left font-mono text-label text-fg-1"
|
|
146
|
+
onClick={() => {
|
|
147
|
+
const next = !open
|
|
148
|
+
setOpen(next)
|
|
149
|
+
if (next && item.truncated && item.expanded === undefined && !item.missing) {
|
|
150
|
+
void actions.loadOutput(shellId)
|
|
151
|
+
}
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
{shellLabel(item)}
|
|
155
|
+
</button>
|
|
156
|
+
<span className={cn('shrink-0 text-label', failed ? 'text-danger' : 'text-fg-4')}>{shellStatusText(item)}</span>
|
|
157
|
+
{actions.open ? (
|
|
158
|
+
<button
|
|
159
|
+
type="button"
|
|
160
|
+
aria-label="Open terminal"
|
|
161
|
+
title="Open terminal"
|
|
162
|
+
className="shrink-0 text-label text-fg-3 hover:text-fg-1"
|
|
163
|
+
onClick={() => actions.open?.(shellId)}
|
|
164
|
+
>
|
|
165
|
+
⤢
|
|
166
|
+
</button>
|
|
167
|
+
) : null}
|
|
168
|
+
{running ? (
|
|
169
|
+
<button
|
|
170
|
+
type="button"
|
|
171
|
+
aria-label="Kill this shell"
|
|
172
|
+
className="shrink-0 text-label text-fg-3"
|
|
173
|
+
onClick={() => void actions.kill(shellId)}
|
|
174
|
+
>
|
|
175
|
+
✕
|
|
176
|
+
</button>
|
|
177
|
+
) : null}
|
|
178
|
+
</div>
|
|
179
|
+
{lines.length > 0 ? (
|
|
180
|
+
<pre className="overflow-x-auto border-t border-border px-3 py-2 font-mono text-label whitespace-pre-wrap text-fg-2">
|
|
181
|
+
{lines.join('\n')}
|
|
182
|
+
</pre>
|
|
183
|
+
) : null}
|
|
184
|
+
{footer ? <div className="px-3 pb-2 text-label text-fg-4">{footer}</div> : null}
|
|
185
|
+
</div>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
116
189
|
function CompactionRow({ item }: { item: Extract<TranscriptItem, { kind: 'compaction' }> }) {
|
|
117
190
|
return (
|
|
118
191
|
<div data-slot="compaction" className="flex items-center gap-2 py-1">
|
|
@@ -3,7 +3,7 @@ import { formatCost, formatDuration, toolInputPreview } from '../../lib/format.t
|
|
|
3
3
|
import { parentOf } from '../terminal/blocks.ts'
|
|
4
4
|
|
|
5
5
|
export type Lane = 'l' | 'r' | 'f'
|
|
6
|
-
export type MarkKind = 'user' | 'subagent' | 'turn' | 'turnFailed' | 'toolFailed' | 'error' | 'approval' | 'recap' | 'bookmark'
|
|
6
|
+
export type MarkKind = 'user' | 'subagent' | 'shell' | 'turn' | 'turnFailed' | 'toolFailed' | 'error' | 'approval' | 'recap' | 'bookmark'
|
|
7
7
|
|
|
8
8
|
export type Mark = {
|
|
9
9
|
kind: MarkKind
|
|
@@ -32,6 +32,7 @@ export function nearestMember(cluster: Cluster, y: number): Mark | undefined {
|
|
|
32
32
|
export const LANE: Record<MarkKind, Lane> = {
|
|
33
33
|
user: 'l',
|
|
34
34
|
subagent: 'l',
|
|
35
|
+
shell: 'l',
|
|
35
36
|
turn: 'r',
|
|
36
37
|
turnFailed: 'r',
|
|
37
38
|
toolFailed: 'r',
|
|
@@ -50,12 +51,14 @@ export const LOUDNESS: Record<MarkKind, number> = {
|
|
|
50
51
|
turn: 2,
|
|
51
52
|
bookmark: 1,
|
|
52
53
|
subagent: 1,
|
|
54
|
+
shell: 1,
|
|
53
55
|
recap: 0,
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
export const KIND_NAME: Record<MarkKind, string> = {
|
|
57
59
|
user: 'you',
|
|
58
60
|
subagent: 'sub-agent',
|
|
61
|
+
shell: 'shell',
|
|
59
62
|
turn: 'response · turn end',
|
|
60
63
|
turnFailed: 'turn failed',
|
|
61
64
|
toolFailed: 'tool failed',
|
|
@@ -79,6 +82,9 @@ export function excerpt(item: TranscriptItem): string {
|
|
|
79
82
|
case 'notice': {
|
|
80
83
|
return item.text
|
|
81
84
|
}
|
|
85
|
+
case 'shell': {
|
|
86
|
+
return item.shell.command
|
|
87
|
+
}
|
|
82
88
|
case 'tool_call': {
|
|
83
89
|
return `${item.name}(${toolInputPreview(item.input)})`
|
|
84
90
|
}
|
|
@@ -139,6 +145,8 @@ export function buildMarks(
|
|
|
139
145
|
closeSegment()
|
|
140
146
|
} else if (item.kind === 'notice' && item.level === 'error') {
|
|
141
147
|
marks.push({ kind: 'error', itemIndex: index })
|
|
148
|
+
} else if (item.kind === 'shell') {
|
|
149
|
+
marks.push({ kind: 'shell', itemIndex: index })
|
|
142
150
|
} else if (
|
|
143
151
|
item.kind === 'tool_call' &&
|
|
144
152
|
parentOf(item) === frameParentId &&
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
export type ShellActions = {
|
|
4
|
+
loadOutput: (shellId: string) => Promise<boolean>
|
|
5
|
+
verify: (shellId: string) => Promise<boolean>
|
|
6
|
+
kill: (shellId: string) => Promise<boolean>
|
|
7
|
+
// Drill in to the shell's terminal. Absent when the host has no frame to open one in.
|
|
8
|
+
open?: (shellId: string) => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const NOOP: ShellActions = {
|
|
12
|
+
loadOutput: async () => false,
|
|
13
|
+
verify: async () => false,
|
|
14
|
+
kill: async () => false,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ShellActionsContext = createContext<ShellActions>(NOOP)
|
|
18
|
+
|
|
19
|
+
export function ShellActionsProvider({ value, children }: { value: ShellActions | undefined; children: ReactNode }) {
|
|
20
|
+
return <ShellActionsContext.Provider value={value ?? NOOP}>{children}</ShellActionsContext.Provider>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useShellActions(): ShellActions {
|
|
24
|
+
return useContext(ShellActionsContext)
|
|
25
|
+
}
|