@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.
Files changed (36) hide show
  1. package/build/{SessionPanel-CJwp5B3q.d.mts → SessionPanel-BVKqPs_3.d.mts} +9 -3
  2. package/build/index.d.mts +84 -6
  3. package/build/index.mjs +83 -37
  4. package/build/index.mjs.map +1 -1
  5. package/build/scoped.css +24 -3
  6. package/build/{use-path-links-CepFsMdW.mjs → use-path-links-BgWXmfAS.mjs} +849 -236
  7. package/build/use-path-links-BgWXmfAS.mjs.map +1 -0
  8. package/build/workspace.d.mts +19 -4
  9. package/build/workspace.mjs +7 -4
  10. package/build/workspace.mjs.map +1 -1
  11. package/package.json +16 -6
  12. package/src/components/agent/Composer.tsx +18 -13
  13. package/src/components/agent/SessionBrowser.tsx +24 -1
  14. package/src/components/agent/SessionItem.tsx +13 -3
  15. package/src/components/agent/SessionPanel.tsx +264 -211
  16. package/src/components/agent/SessionSteps.tsx +78 -27
  17. package/src/components/agent/SessionWorkspace.tsx +10 -1
  18. package/src/components/agent/ShellStrip.tsx +71 -0
  19. package/src/components/agent/ShellTerminal.tsx +170 -0
  20. package/src/components/agent/SubagentToggle.tsx +7 -1
  21. package/src/components/agent/TranscriptItemView.tsx +74 -1
  22. package/src/components/agent/scrubber-marks.ts +9 -1
  23. package/src/components/agent/shell-actions.tsx +25 -0
  24. package/src/components/agent/use-shell-frame.ts +111 -0
  25. package/src/components/terminal/TerminalTranscript.tsx +4 -0
  26. package/src/components/terminal/affordances.tsx +37 -0
  27. package/src/components/terminal/height.ts +14 -0
  28. package/src/components/terminal/items.tsx +72 -3
  29. package/src/components/terminal/press.tsx +5 -3
  30. package/src/components/terminal/scrubber.tsx +9 -1
  31. package/src/components/terminal/shell-row.ts +106 -0
  32. package/src/index.ts +12 -1
  33. package/src/styles/terminal.css +11 -0
  34. package/src/styles/theme.css +3 -0
  35. package/src/workspace.ts +1 -0
  36. 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(info: SessionInfo, onSelect: (toolUseId: string) => void, show: SubagentDisplay = 'all'): Step[] {
18
- return visibleSubagents(info, show)
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
- <button
49
- type="button"
50
- title={step.title}
51
- aria-current={active || undefined}
52
- onClick={(e) => {
53
- e.stopPropagation()
54
- onSelect()
55
- }}
56
- className={cn(
57
- 'flex w-full items-center gap-1.5 rounded-[4px] py-1 pr-2.5 pl-3.5',
58
- 'text-left text-micro outline-none',
59
- active ? 'bg-row-selected' : 'hover:bg-row-active',
60
- body,
61
- )}
62
- >
63
- <StepIcon state={step.state} />
64
- <span className="min-w-0 flex-1 truncate">{step.label}</span>
65
- {step.detail ? <span className="shrink-0 tabular-nums text-fg-4">{step.detail}</span> : null}
66
- <ArrowRight className="size-3.5 shrink-0 text-fg-4" />
67
- </button>
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({ state }: { state: Step['state'] }) {
72
- switch (state) {
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 variant="ghost" size="icon-sm" aria-label={`Sub-agents: ${meta.label}`} title={meta.hint} className={cn('w-9 gap-0.5', className)}>
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 type { TranscriptItem } from '@workerdeck/react'
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
+ }