@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.
Files changed (145) hide show
  1. package/build/{SessionPanel-DgwjH4Ve.mjs → SessionPanel-DDgBkHsi.mjs} +2196 -3364
  2. package/build/SessionPanel-DDgBkHsi.mjs.map +1 -0
  3. package/build/SessionPanel-E_L_ldQ5.d.mts +207 -0
  4. package/build/format-B8ZM9LPy.d.mts +16 -0
  5. package/build/format.d.mts +8 -75
  6. package/build/format.mjs +2 -2
  7. package/build/index.d.mts +63 -1138
  8. package/build/index.mjs +4 -300
  9. package/build/index.mjs.map +1 -1
  10. package/build/scoped.css +80 -24
  11. package/build/{status-BE-zg88x.mjs → status-C0HSLZas.mjs} +14 -85
  12. package/build/status-C0HSLZas.mjs.map +1 -0
  13. package/build/workspace.d.mts +9 -124
  14. package/build/workspace.mjs +13 -143
  15. package/build/workspace.mjs.map +1 -1
  16. package/package.json +4 -4
  17. package/src/components/agent/CodeEditor.tsx +31 -103
  18. package/src/components/agent/Composer.tsx +114 -333
  19. package/src/components/agent/ContextDialog.tsx +20 -41
  20. package/src/components/agent/ContextRing.tsx +6 -28
  21. package/src/components/agent/Conversation.tsx +21 -33
  22. package/src/components/agent/EditorTabs.tsx +39 -67
  23. package/src/components/agent/EngineIcon.tsx +29 -72
  24. package/src/components/agent/EntryIcon.tsx +16 -0
  25. package/src/components/agent/FileCard.tsx +10 -16
  26. package/src/components/agent/FileTree.tsx +52 -125
  27. package/src/components/agent/FileViewer.tsx +30 -60
  28. package/src/components/agent/HostFilesDialog.tsx +40 -69
  29. package/src/components/agent/Loader.tsx +3 -24
  30. package/src/components/agent/McpDialog.tsx +64 -137
  31. package/src/components/agent/Message.tsx +2 -21
  32. package/src/components/agent/ModelSelect.tsx +33 -62
  33. package/src/components/agent/PermissionModeSelect.tsx +21 -79
  34. package/src/components/agent/PermissionPrompt.tsx +59 -63
  35. package/src/components/agent/ProjectIcon.tsx +228 -93
  36. package/src/components/agent/PromptTokenText.tsx +10 -16
  37. package/src/components/agent/QuestionPrompt.tsx +43 -64
  38. package/src/components/agent/Reasoning.tsx +13 -16
  39. package/src/components/agent/Response.tsx +4 -6
  40. package/src/components/agent/Scrubber.tsx +31 -80
  41. package/src/components/agent/SessionBrowser.tsx +64 -243
  42. package/src/components/agent/SessionEmptyState.tsx +10 -40
  43. package/src/components/agent/SessionInfoDialog.tsx +46 -78
  44. package/src/components/agent/SessionItem.tsx +53 -320
  45. package/src/components/agent/SessionList.tsx +23 -39
  46. package/src/components/agent/SessionPanel.tsx +345 -923
  47. package/src/components/agent/SessionStatusIcon.tsx +14 -28
  48. package/src/components/agent/SessionSteps.tsx +34 -187
  49. package/src/components/agent/SessionWorkspace.tsx +108 -215
  50. package/src/components/agent/SkillsDialog.tsx +35 -80
  51. package/src/components/agent/StatusBar.tsx +51 -136
  52. package/src/components/agent/SubagentStrip.tsx +12 -77
  53. package/src/components/agent/ToolCallCard.tsx +94 -135
  54. package/src/components/agent/Transcript.tsx +103 -1288
  55. package/src/components/agent/TranscriptItemView.tsx +155 -0
  56. package/src/components/agent/TranscriptRows.tsx +418 -0
  57. package/src/components/agent/UsageDialog.tsx +7 -29
  58. package/src/components/agent/UsageMeters.tsx +18 -73
  59. package/src/components/agent/pulse.tsx +9 -32
  60. package/src/components/agent/scrubber-marks.ts +33 -103
  61. package/src/components/agent/status.ts +1 -5
  62. package/src/components/agent/tool-result-fetch.tsx +3 -25
  63. package/src/components/agent/tool-result-image.tsx +32 -111
  64. package/src/components/agent/transcript-rows.ts +41 -118
  65. package/src/components/agent/transcript-variant.tsx +3 -80
  66. package/src/components/agent/use-height-epoch.ts +10 -24
  67. package/src/components/agent/use-path-links.ts +45 -63
  68. package/src/components/agent/use-subagent-frame.ts +106 -0
  69. package/src/components/agent/use-transcript-jumps.ts +49 -107
  70. package/src/components/prompt-area/animated-placeholder.tsx +6 -5
  71. package/src/components/prompt-area/clipboard-helpers.ts +26 -21
  72. package/src/components/prompt-area/cursor-helpers.ts +79 -37
  73. package/src/components/prompt-area/dom-helpers.ts +97 -69
  74. package/src/components/prompt-area/file-strip.tsx +90 -80
  75. package/src/components/prompt-area/html-to-markdown.ts +67 -46
  76. package/src/components/prompt-area/image-strip.tsx +8 -18
  77. package/src/components/prompt-area/index.ts +2 -15
  78. package/src/components/prompt-area/prompt-area-engine.ts +100 -115
  79. package/src/components/prompt-area/prompt-area-list-ops.ts +66 -59
  80. package/src/components/prompt-area/prompt-area.tsx +31 -38
  81. package/src/components/prompt-area/remove-button.tsx +3 -9
  82. package/src/components/prompt-area/segment-helpers.ts +4 -12
  83. package/src/components/prompt-area/trigger-popover.tsx +15 -27
  84. package/src/components/prompt-area/trigger-presets.ts +0 -10
  85. package/src/components/prompt-area/types.ts +3 -8
  86. package/src/components/prompt-area/use-chip-editing.ts +305 -0
  87. package/src/components/prompt-area/use-markdown-mode.ts +7 -17
  88. package/src/components/prompt-area/use-prompt-area-events.ts +80 -86
  89. package/src/components/prompt-area/use-prompt-area-keydown.ts +598 -0
  90. package/src/components/prompt-area/use-prompt-area-state.ts +5 -23
  91. package/src/components/prompt-area/use-prompt-area.ts +170 -896
  92. package/src/components/prompt-area/use-trigger-search.ts +21 -19
  93. package/src/components/terminal/PermissionPrompt.tsx +56 -59
  94. package/src/components/terminal/QuestionPrompt.tsx +28 -79
  95. package/src/components/terminal/StatusLine.tsx +13 -63
  96. package/src/components/terminal/TerminalTranscript.tsx +44 -160
  97. package/src/components/terminal/affordances.tsx +68 -98
  98. package/src/components/terminal/blocks.ts +20 -156
  99. package/src/components/terminal/diff.tsx +19 -67
  100. package/src/components/terminal/height.ts +163 -313
  101. package/src/components/terminal/image-box.ts +0 -44
  102. package/src/components/terminal/items.tsx +150 -305
  103. package/src/components/terminal/markdown.tsx +36 -95
  104. package/src/components/terminal/press.tsx +26 -53
  105. package/src/components/terminal/prompt.tsx +40 -170
  106. package/src/components/terminal/result-preview.ts +8 -56
  107. package/src/components/terminal/row.tsx +11 -98
  108. package/src/components/terminal/scrubber.tsx +108 -401
  109. package/src/components/terminal/surface.tsx +6 -54
  110. package/src/components/terminal/todos.ts +66 -0
  111. package/src/components/terminal/tool-run.ts +21 -192
  112. package/src/components/ui/AlertDialog.tsx +8 -23
  113. package/src/components/ui/Badge.tsx +18 -29
  114. package/src/components/ui/Button.tsx +7 -15
  115. package/src/components/ui/Card.tsx +2 -13
  116. package/src/components/ui/CodeBlock.tsx +10 -37
  117. package/src/components/ui/CopyButton.tsx +10 -23
  118. package/src/components/ui/Dialog.tsx +23 -36
  119. package/src/components/ui/Empty.tsx +6 -29
  120. package/src/components/ui/Input.tsx +14 -16
  121. package/src/components/ui/Menu.tsx +6 -18
  122. package/src/components/ui/PortalScope.tsx +1 -21
  123. package/src/components/ui/ProgressRing.tsx +8 -19
  124. package/src/components/ui/Select.tsx +18 -31
  125. package/src/components/ui/Sonner.tsx +1 -2
  126. package/src/components/ui/Spinner.tsx +1 -1
  127. package/src/components/ui/Splitter.tsx +30 -64
  128. package/src/components/ui/Textarea.tsx +2 -5
  129. package/src/components/ui/Tooltip.tsx +3 -13
  130. package/src/format.ts +0 -9
  131. package/src/index.ts +16 -113
  132. package/src/lib/clipboard.ts +8 -28
  133. package/src/lib/css.ts +8 -0
  134. package/src/lib/format.ts +101 -86
  135. package/src/lib/plan-request.ts +16 -0
  136. package/src/lib/status.ts +45 -95
  137. package/src/lib/tool-icon.ts +39 -47
  138. package/src/lib/utils.ts +1 -3
  139. package/src/styles/terminal.css +81 -49
  140. package/src/styles/theme.css +136 -19
  141. package/src/workspace.ts +1 -24
  142. package/build/SessionPanel-13l25ubU.d.mts +0 -609
  143. package/build/SessionPanel-DgwjH4Ve.mjs.map +0 -1
  144. package/build/format-ljc3lKpA.d.mts +0 -59
  145. package/build/status-BE-zg88x.mjs.map +0 -1
@@ -3,10 +3,6 @@
3
3
  import { useCallback, useEffect, useRef, useState } from 'react'
4
4
  import type { TriggerConfig, TriggerSuggestion } from './types.ts'
5
5
 
6
- // ---------------------------------------------------------------------------
7
- // Types
8
- // ---------------------------------------------------------------------------
9
-
10
6
  type UseTriggerSearchReturn = {
11
7
  suggestions: TriggerSuggestion[]
12
8
  suggestionsLoading: boolean
@@ -17,10 +13,6 @@ type UseTriggerSearchReturn = {
17
13
  reset: () => void
18
14
  }
19
15
 
20
- // ---------------------------------------------------------------------------
21
- // Hook
22
- // ---------------------------------------------------------------------------
23
-
24
16
  /**
25
17
  * Manages async trigger search lifecycle: debouncing, AbortController
26
18
  * cancellation, race-condition prevention, loading/error state.
@@ -35,25 +27,28 @@ export function useTriggerSearch(): UseTriggerSearchReturn {
35
27
 
36
28
  // Version counter – belt-and-suspenders alongside AbortController
37
29
  const searchVersion = useRef(0)
38
- // AbortController for cancelling in-flight async searches
39
30
  const abortController = useRef<AbortController | null>(null)
40
- // Debounce timer for search queries
41
31
  const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
42
32
 
43
33
  const reset = useCallback(() => {
44
34
  abortController.current?.abort()
45
- if (debounceTimer.current) clearTimeout(debounceTimer.current)
35
+ if (debounceTimer.current) {
36
+ clearTimeout(debounceTimer.current)
37
+ }
46
38
  setSuggestions([])
47
39
  setSuggestionsLoading(false)
48
40
  setSuggestionsError(null)
49
41
  }, [])
50
42
 
51
43
  const search = useCallback((query: string, config: TriggerConfig) => {
52
- if (!config.onSearch) return
44
+ if (!config.onSearch) {
45
+ return
46
+ }
53
47
 
54
- // Cancel any previous in-flight request and pending debounce
55
48
  abortController.current?.abort()
56
- if (debounceTimer.current) clearTimeout(debounceTimer.current)
49
+ if (debounceTimer.current) {
50
+ clearTimeout(debounceTimer.current)
51
+ }
57
52
 
58
53
  setSuggestionsLoading(true)
59
54
  setSuggestionsError(null)
@@ -70,14 +65,20 @@ export function useTriggerSearch(): UseTriggerSearchReturn {
70
65
  if (result instanceof Promise) {
71
66
  void result.then(
72
67
  (items) => {
73
- if (controller.signal.aborted || searchVersion.current !== version) return
68
+ if (controller.signal.aborted || searchVersion.current !== version) {
69
+ return
70
+ }
74
71
  setSuggestions(items)
75
72
  setSuggestionsLoading(false)
76
73
  },
77
74
  (error: unknown) => {
78
- if (controller.signal.aborted || searchVersion.current !== version) return
75
+ if (controller.signal.aborted || searchVersion.current !== version) {
76
+ return
77
+ }
79
78
  // Silently ignore AbortError (expected when superseded)
80
- if (error instanceof DOMException && error.name === 'AbortError') return
79
+ if (error instanceof DOMException && error.name === 'AbortError') {
80
+ return
81
+ }
81
82
  setSuggestionsError(error instanceof Error ? error.message : 'Search failed')
82
83
  setSuggestionsLoading(false)
83
84
  onSearchError?.(error)
@@ -97,11 +98,12 @@ export function useTriggerSearch(): UseTriggerSearchReturn {
97
98
  }
98
99
  }, [])
99
100
 
100
- // Clean up on unmount
101
101
  useEffect(() => {
102
102
  return () => {
103
103
  abortController.current?.abort()
104
- if (debounceTimer.current) clearTimeout(debounceTimer.current)
104
+ if (debounceTimer.current) {
105
+ clearTimeout(debounceTimer.current)
106
+ }
105
107
  }
106
108
  }, [])
107
109
 
@@ -1,41 +1,22 @@
1
1
  import { useState } from 'react'
2
2
  import type { PermissionRequest } from '@workerdeck/protocol'
3
3
  import { toolInputPreview } from '../../lib/format.ts'
4
+ import { planFromRequest } from '../../lib/plan-request.ts'
4
5
  import { TerminalDiff, previewPatch } from './diff.tsx'
6
+ import { TerminalMarkdown } from './markdown.tsx'
5
7
  import { Choices, Hint, PromptInput, PromptTitle, Rule } from './prompt.tsx'
6
8
  import { Blank, Row } from './row.tsx'
7
9
 
8
- /**
9
- * The approval, as the CLI draws it: a rule, what is being asked and about what,
10
- * the change itself, a dashed rule, the question, numbered answers, and the keys.
11
- *
12
- * Two things it does that the card version does not, both taken from the CLI:
13
- *
14
- * - **It shows the change, not the payload.** For a file edit, `old_string` and
15
- * `new_string` are a diff — so it renders one, rather than the JSON input a
16
- * reader would have to diff in their head. Without line numbers, necessarily:
17
- * the edit has not happened and this client has never read the file (see
18
- * {@link previewPatch}). Everything else falls back to the input.
19
- * - **The heading is the engine's own sentence.** `displayName` ("Edit file")
20
- * over `title`, never "wants to use {tool}" composed here — for codex an
21
- * approval is an *escalation after a sandbox refusal*, and the runner has
22
- * already written the sentence that says so.
23
- */
10
+ const PLAN_SCROLL = { maxHeight: 'min(24rem, 50vh)', overflowY: 'auto' } as const
11
+
24
12
  export interface TerminalPermissionPromptProps {
25
13
  request: PermissionRequest
26
14
  onApprove: (requestId: string) => void
27
- /** `message` is fed back to the agent, which can then try something else;
28
- * `interrupt` also stops the turn. */
29
15
  onDeny: (requestId: string, message?: string, interrupt?: boolean) => void
30
16
  className?: string
31
17
  }
32
18
 
33
- export function TerminalPermissionPrompt({
34
- request,
35
- onApprove,
36
- onDeny,
37
- className,
38
- }: TerminalPermissionPromptProps) {
19
+ export function TerminalPermissionPrompt({ request, onApprove, onDeny, className }: TerminalPermissionPromptProps) {
39
20
  const [focused, setFocused] = useState(0)
40
21
  const [denying, setDenying] = useState(false)
41
22
  const [reason, setReason] = useState('')
@@ -47,47 +28,61 @@ export function TerminalPermissionPrompt({
47
28
  setDenying(false)
48
29
  }
49
30
 
50
- const patch = previewPatch(request.input)
51
- const summary = toolInputPreview(request.input)
52
- const heading = request.displayName ?? request.title ?? 'Permission needed'
53
- // The path is the subject when there is one; otherwise the input preview says
54
- // what this is about, which for a Bash approval is the command itself.
31
+ const plan = planFromRequest(request)
32
+ const patch = plan ? undefined : previewPatch(request.input)
33
+ const summary = plan ? '' : toolInputPreview(request.input)
34
+ const heading = plan ? 'Plan ready for review' : (request.displayName ?? request.title ?? 'Permission needed')
55
35
  const subject = patch?.path ?? (summary || undefined)
56
36
 
57
- // "Deny" opens the reason field rather than acting, because a denial the agent
58
- // can learn from is the one worth defaulting to — an empty field still denies.
59
- const options = [
60
- { key: 'allow', label: 'Yes' },
61
- {
62
- key: 'deny',
63
- label: 'No, and tell the agent what to do differently',
64
- detail: denying ? (
65
- <PromptInput
66
- value={reason}
67
- onChange={setReason}
68
- onSubmit={() => deny(false)}
69
- onCancel={() => setDenying(false)}
70
- placeholder='Reason (optional) — the agent reads this and can try something else'
71
- />
72
- ) : undefined,
73
- },
74
- { key: 'stop', label: 'No, and stop the turn', danger: true },
75
- ]
37
+ const reasonInput = denying ? (
38
+ <PromptInput
39
+ value={reason}
40
+ onChange={setReason}
41
+ onSubmit={() => deny(false)}
42
+ onCancel={() => setDenying(false)}
43
+ placeholder={
44
+ plan
45
+ ? 'What should change? (optional) — the agent keeps planning and reads this'
46
+ : 'Reason (optional) — the agent reads this and can try something else'
47
+ }
48
+ />
49
+ ) : undefined
50
+
51
+ const options = plan
52
+ ? [
53
+ { key: 'allow', label: 'Approve plan' },
54
+ { key: 'deny', label: 'Keep planning tell it what to change', detail: reasonInput },
55
+ { key: 'stop', label: 'No, and stop the turn', danger: true },
56
+ ]
57
+ : [
58
+ { key: 'allow', label: 'Yes' },
59
+ { key: 'deny', label: 'No, and tell the agent what to do differently', detail: reasonInput },
60
+ { key: 'stop', label: 'No, and stop the turn', danger: true },
61
+ ]
76
62
 
77
63
  return (
78
64
  <div
79
- data-slot='permission-prompt'
65
+ data-slot="permission-prompt"
80
66
  className={className}
81
67
  onKeyDown={(event) => {
82
68
  if (event.key === 'Escape') {
83
69
  event.preventDefault()
84
70
  deny(false)
85
71
  }
86
- }}>
72
+ }}
73
+ >
87
74
  <Rule />
88
75
  <PromptTitle title={heading} subject={subject} />
89
76
  <Blank />
90
- {patch ? (
77
+ {plan ? (
78
+ <>
79
+ <div style={PLAN_SCROLL}>
80
+ <TerminalMarkdown>{plan}</TerminalMarkdown>
81
+ </div>
82
+ <Blank />
83
+ <Rule dashed />
84
+ </>
85
+ ) : patch ? (
91
86
  <>
92
87
  <TerminalDiff patch={patch} />
93
88
  <Blank />
@@ -95,14 +90,12 @@ export function TerminalPermissionPrompt({
95
90
  </>
96
91
  ) : request.description ? (
97
92
  <>
98
- <Row tone='dim'>{request.description}</Row>
93
+ <Row tone="dim">{request.description}</Row>
99
94
  <Blank />
100
95
  </>
101
96
  ) : null}
102
- {request.decisionReason ? <Row tone='faint'>{request.decisionReason}</Row> : null}
103
- <Row>
104
- {request.title ?? `Do you want to proceed?`}
105
- </Row>
97
+ {request.decisionReason ? <Row tone="faint">{request.decisionReason}</Row> : null}
98
+ <Row>{plan ? 'Ready to implement this plan?' : (request.title ?? `Do you want to proceed?`)}</Row>
106
99
  <Choices
107
100
  label={heading}
108
101
  options={options}
@@ -110,9 +103,13 @@ export function TerminalPermissionPrompt({
110
103
  onFocus={setFocused}
111
104
  active={!denying}
112
105
  onChoose={(index) => {
113
- if (index === 0) onApprove(request.id)
114
- else if (index === 1) setDenying(true)
115
- else deny(true)
106
+ if (index === 0) {
107
+ onApprove(request.id)
108
+ } else if (index === 1) {
109
+ setDenying(true)
110
+ } else {
111
+ deny(true)
112
+ }
116
113
  }}
117
114
  />
118
115
  <Blank />
@@ -4,56 +4,29 @@ import { parseUserQuestions } from '../agent/QuestionPrompt.tsx'
4
4
  import { Box, Choices, Hint, PromptInput, Rule, TabStrip, type Choice } from './prompt.tsx'
5
5
  import { Blank, Ink, Row } from './row.tsx'
6
6
 
7
- /**
8
- * The AskUserQuestion form, in the CLI's shape: **one question at a time**,
9
- * behind a strip of chips, ending in a review step.
10
- *
11
- * The card version stacks every question on screen at once, which is right for a
12
- * dialog and wrong here for a reason worth stating: a terminal form is answered
13
- * with the keyboard, and a stacked form has no answer to "where does `Tab` go" —
14
- * it has as many focus targets as there are options across every question. One
15
- * question at a time makes the keys unambiguous (`↑↓` within, `Tab` between,
16
- * `Enter` to take), and it is why the chips exist: something has to say that
17
- * answering the first of three is not finishing.
18
- *
19
- * The review step is the other half of that. Answers given one screen at a time
20
- * are answers you cannot see together, so the last chip shows all of them and
21
- * asks once more before they go back to the model.
22
- */
23
-
24
7
  type Selection = { labels: string[]; other: string; otherActive: boolean }
25
8
 
26
9
  const EMPTY: Selection = { labels: [], other: '', otherActive: false }
27
10
 
28
- /** A question's answer: chosen label(s), comma-joined, with any free-text
29
- * "Other" appended — the shape the CLI's own UI puts in `updatedInput.answers`. */
30
11
  function answerFor(selection: Selection): string {
31
12
  const parts = [...selection.labels]
32
- if (selection.otherActive && selection.other.trim()) parts.push(selection.other.trim())
13
+ if (selection.otherActive && selection.other.trim()) {
14
+ parts.push(selection.other.trim())
15
+ }
33
16
  return parts.join(', ')
34
17
  }
35
18
 
36
19
  export interface TerminalQuestionPromptProps {
37
- /** A pending permission whose toolName is 'AskUserQuestion'. */
38
20
  request: PermissionRequest
39
- /** Allow the tool with `updatedInput` (the original input plus `answers`). */
40
21
  onAnswer: (requestId: string, updatedInput: Record<string, unknown>) => void
41
- /** Deny the tool — the model proceeds without an answer. */
42
22
  onDismiss: (requestId: string, message?: string) => void
43
23
  className?: string
44
24
  }
45
25
 
46
- export function TerminalQuestionPrompt({
47
- request,
48
- onAnswer,
49
- onDismiss,
50
- className,
51
- }: TerminalQuestionPromptProps) {
26
+ export function TerminalQuestionPrompt({ request, onAnswer, onDismiss, className }: TerminalQuestionPromptProps) {
52
27
  const questions = parseUserQuestions(request.input)
53
28
  const [selections, setSelections] = useState<Selection[]>(() => questions.map(() => EMPTY))
54
29
  const [cursors, setCursors] = useState<number[]>(() => questions.map(() => 0))
55
- // Tabs are the questions plus the review step, so `questions.length` is the
56
- // review — one index space, which is what makes Tab a single `+1`.
57
30
  const [tab, setTab] = useState(0)
58
31
  const [reviewCursor, setReviewCursor] = useState(0)
59
32
 
@@ -65,19 +38,18 @@ export function TerminalQuestionPrompt({
65
38
  const update = (index: number, patch: Partial<Selection>) =>
66
39
  setSelections((prev) => prev.map((s, i) => (i === index ? { ...s, ...patch } : s)))
67
40
 
68
- // Functional, and it has to be: reading `selections` from the render closure
69
- // means two toggles in one tick both compute from the same base and the second
70
- // silently drops the first. A multi-select is exactly where that happens.
71
41
  const toggle = (index: number, label: string, multiSelect: boolean) => {
72
42
  setSelections((prev) =>
73
43
  prev.map((current, i) => {
74
- if (i !== index) return current
75
- if (!multiSelect) return { ...current, labels: [label], otherActive: false }
44
+ if (i !== index) {
45
+ return current
46
+ }
47
+ if (!multiSelect) {
48
+ return { ...current, labels: [label], otherActive: false }
49
+ }
76
50
  return {
77
51
  ...current,
78
- labels: current.labels.includes(label)
79
- ? current.labels.filter((l) => l !== label)
80
- : [...current.labels, label],
52
+ labels: current.labels.includes(label) ? current.labels.filter((l) => l !== label) : [...current.labels, label],
81
53
  }
82
54
  }),
83
55
  )
@@ -93,12 +65,11 @@ export function TerminalQuestionPrompt({
93
65
 
94
66
  const dismiss = () => onDismiss(request.id, 'Question dismissed by user')
95
67
 
96
- const move = (delta: number) =>
97
- setTab((current) => Math.min(questions.length, Math.max(0, current + delta)))
68
+ const move = (delta: number) => setTab((current) => Math.min(questions.length, Math.max(0, current + delta)))
98
69
 
99
70
  return (
100
71
  <div
101
- data-slot='question-prompt'
72
+ data-slot="question-prompt"
102
73
  className={className}
103
74
  onKeyDown={(event) => {
104
75
  if (event.key === 'Escape') {
@@ -110,7 +81,8 @@ export function TerminalQuestionPrompt({
110
81
  event.preventDefault()
111
82
  move(event.shiftKey ? -1 : 1)
112
83
  }
113
- }}>
84
+ }}
85
+ >
114
86
  <Rule />
115
87
  <TabStrip
116
88
  tabs={[
@@ -137,10 +109,7 @@ export function TerminalQuestionPrompt({
137
109
  />
138
110
  ) : (
139
111
  <QuestionStep
140
- // Keyed by question, and load-bearing: without it React reuses the
141
- // step across a Tab and the option list never re-arms its focus, so
142
- // the keyboard lands on nothing and the next `3` or `Esc` goes
143
- // nowhere. Remounting is also what re-runs the focus takeover.
112
+ // Keyed by question and load-bearing: without it React reuses the step across a Tab and the option list never re-arms its focus.
144
113
  key={tab}
145
114
  question={questions[tab]!}
146
115
  selection={selection}
@@ -153,9 +122,7 @@ export function TerminalQuestionPrompt({
153
122
  />
154
123
  )}
155
124
  <Blank />
156
- <Hint>
157
- Enter to select · ↑/↓ to navigate · Tab to switch questions · Esc to cancel
158
- </Hint>
125
+ <Hint>Enter to select · ↑/↓ to navigate · Tab to switch questions · Esc to cancel</Hint>
159
126
  </div>
160
127
  )
161
128
  }
@@ -188,21 +155,14 @@ function QuestionStep({
188
155
  key: option.label,
189
156
  label: option.label,
190
157
  description: option.description,
191
- // Markers only where there is state to keep: a multi-select accumulates
192
- // and has to show what is in the set, a one-of answers itself by being
193
- // chosen and moves on — a column of empty radios in front of it would be
194
- // three characters of nothing on every row. The CLI draws it the same way.
195
158
  checked: multiSelect ? selected : undefined,
196
159
  marker: 'check',
197
160
  selected: !multiSelect && selected,
198
- // A preview shows on **focus**, not only on selection: walking a list
199
- // with the arrow keys, "what does this one look like" is the question
200
- // the cursor is asking.
201
161
  detail:
202
162
  option.preview && cursor === index ? (
203
163
  <Box>
204
164
  {option.preview.split('\n').map((line, row) => (
205
- <Row key={row} columns={0} tone='dim'>
165
+ <Row key={row} columns={0} tone="dim">
206
166
  {line || ' '}
207
167
  </Row>
208
168
  ))}
@@ -213,8 +173,6 @@ function QuestionStep({
213
173
  {
214
174
  key: '__other',
215
175
  label: 'Other…',
216
- // Always markered, in both kinds: this row is a *mode* (the field is open
217
- // or it isn't), which the reader cannot see any other way.
218
176
  checked: selection.otherActive,
219
177
  marker: 'check' as const,
220
178
  detail: selection.otherActive ? (
@@ -223,20 +181,17 @@ function QuestionStep({
223
181
  onChange={(value) => onOther({ other: value })}
224
182
  onSubmit={onAdvance}
225
183
  onCancel={() => onOther({ otherActive: false, other: '' })}
226
- placeholder='Type your own answer'
184
+ placeholder="Type your own answer"
227
185
  />
228
186
  ) : undefined,
229
187
  },
230
- // Multi-select has no natural end — every toggle leaves the list where it
231
- // was — so it needs a row that says "done with this one". A one-of answers
232
- // itself by being chosen and advances on its own.
233
188
  ...(multiSelect ? [{ key: '__next', label: 'Submit' }] : []),
234
189
  { key: '__chat', label: 'Chat about this' },
235
190
  ]
236
191
 
237
192
  return (
238
193
  <>
239
- <Row bold tone='bright'>
194
+ <Row bold tone="bright">
240
195
  {question.question}
241
196
  </Row>
242
197
  <Blank />
@@ -249,9 +204,9 @@ function QuestionStep({
249
204
  onChoose={(index) => {
250
205
  if (index < question.options.length) {
251
206
  onToggle(question.options[index]!.label)
252
- // A one-of is finished the moment it is picked; making the reader
253
- // press Tab as well would be a keystroke that answers nothing.
254
- if (!multiSelect) onAdvance()
207
+ if (!multiSelect) {
208
+ onAdvance()
209
+ }
255
210
  return
256
211
  }
257
212
  if (index === question.options.length) {
@@ -269,7 +224,6 @@ function QuestionStep({
269
224
  )
270
225
  }
271
226
 
272
- /** The last chip: every answer together, and one more chance to change it. */
273
227
  function ReviewStep({
274
228
  questions,
275
229
  answers,
@@ -289,30 +243,25 @@ function ReviewStep({
289
243
  }) {
290
244
  return (
291
245
  <>
292
- <Row bold tone='bright'>
246
+ <Row bold tone="bright">
293
247
  Review your answers
294
248
  </Row>
295
249
  <Blank />
296
250
  {questions.map((question, index) => (
297
251
  <div key={index}>
298
- <Row glyph='' glyphTone='dim'>
252
+ <Row glyph="" glyphTone="dim">
299
253
  {question.question}
300
254
  </Row>
301
- <Row indent={1} glyph='' glyphTone='green'>
302
- <Ink tone={answers[index] ? 'green' : 'faint'}>
303
- {answers[index] || 'not answered'}
304
- </Ink>
255
+ <Row indent={1} glyph="" glyphTone="green">
256
+ <Ink tone={answers[index] ? 'green' : 'faint'}>{answers[index] || 'not answered'}</Ink>
305
257
  </Row>
306
258
  </div>
307
259
  ))}
308
260
  <Blank />
309
261
  <Row>{complete ? 'Ready to submit your answers?' : 'Some questions are unanswered.'}</Row>
310
262
  <Choices
311
- label='Submit answers'
263
+ label="Submit answers"
312
264
  options={[
313
- // Offered even when incomplete: an unanswered question is a legitimate
314
- // answer to give, and the model is told which ones were skipped. What
315
- // it must not do is *look* finished, which is what the row above says.
316
265
  { key: 'submit', label: complete ? 'Submit answers' : 'Submit anyway' },
317
266
  { key: 'cancel', label: 'Cancel', danger: true },
318
267
  ]}
@@ -12,25 +12,6 @@ import {
12
12
  } from '../../lib/status.ts'
13
13
  import { Ink, Row, type Tone } from './row.tsx'
14
14
 
15
- /**
16
- * The session's readings, as one line.
17
- *
18
- * The styled bar puts each of these in its own affordance — a progress ring, a
19
- * badge, a tooltip — which is right for a dashboard and wrong here for the usual
20
- * reason: a terminal has one line height, and five widgets of five different
21
- * heights is five different rhythms in a row that is supposed to be quiet. So
22
- * they are words, `·`-separated, exactly as the CLI's own status line writes
23
- * them.
24
- *
25
- * The readings themselves come from the same helpers the styled bar uses
26
- * (`lib/status.ts`), and that matters more than it looks: `statusPresentation`
27
- * is where the rule lives that **a dropped socket outranks the session status**,
28
- * because a status held over a dead socket is a stale reading being presented as
29
- * a live one. Re-deriving that here would be a second answer to a question that
30
- * already has one.
31
- */
32
-
33
- /** Severity → tone. The 80/95 thresholds are `lib/status.ts`'s, not new ones. */
34
15
  const SEVERITY_TONE: Record<StatusSeverity, Tone> = {
35
16
  none: 'dim',
36
17
  warning: 'yellow',
@@ -40,36 +21,19 @@ const SEVERITY_TONE: Record<StatusSeverity, Tone> = {
40
21
  export interface TerminalStatusLineProps {
41
22
  className?: string
42
23
  state: TranscriptState
43
- /** Plan windows to read from, when they should not be the session's own — the
44
- * gateway's per-profile state merged over this transcript's. See
45
- * {@link StatusBarProps.rateLimits}. Absent = `state.rateLimits`. */
46
24
  rateLimits?: Record<string, RateLimitInfo>
47
- /** How the client is doing at reaching the gateway. Wins the status slot when
48
- * the socket is down — see above. */
49
25
  connection?: ConnectionState
50
- /** Opens the panel that answers each reading's own question. Omit and the
51
- * reading is text rather than something to press. */
52
26
  onOpenStatus?: () => void
53
27
  onOpenContext?: () => void
54
28
  onOpenUsage?: () => void
55
29
  }
56
30
 
57
- /** One reading. A button only when it leads somewhere a segment that looks
58
- * pressable and does nothing is worse than plain text. */
59
- function Reading({
60
- tone,
61
- onPress,
62
- label,
63
- children,
64
- }: {
65
- tone?: Tone
66
- onPress?: () => void
67
- label?: string
68
- children: React.ReactNode
69
- }) {
70
- if (!onPress) return <Ink tone={tone}>{children}</Ink>
31
+ function Reading({ tone, onPress, label, children }: { tone?: Tone; onPress?: () => void; label?: string; children: React.ReactNode }) {
32
+ if (!onPress) {
33
+ return <Ink tone={tone}>{children}</Ink>
34
+ }
71
35
  return (
72
- <button type='button' className='term-reading' onClick={onPress} title={label}>
36
+ <button type="button" className="term-reading" onClick={onPress} title={label}>
73
37
  <Ink tone={tone}>{children}</Ink>
74
38
  </button>
75
39
  )
@@ -90,22 +54,14 @@ export function TerminalStatusLine({
90
54
  const model = modelLabel({ model: state.model, models: state.models ?? [] })
91
55
 
92
56
  const parts: React.ReactNode[] = [
93
- <Reading
94
- key='status'
95
- tone={SEVERITY_TONE[presentation.severity]}
96
- onPress={onOpenStatus}
97
- label='Session status'>
57
+ <Reading key="status" tone={SEVERITY_TONE[presentation.severity]} onPress={onOpenStatus} label="Session status">
98
58
  {presentation.label}
99
59
  </Reading>,
100
60
  ]
101
61
 
102
62
  if (usage) {
103
63
  parts.push(
104
- <Reading
105
- key='context'
106
- tone={SEVERITY_TONE[contextSeverity(usage)]}
107
- onPress={onOpenContext}
108
- label='Context window'>
64
+ <Reading key="context" tone={SEVERITY_TONE[contextSeverity(usage)]} onPress={onOpenContext} label="Context window">
109
65
  {Math.round(usage.percentage)}% ctx ({formatTokens(usage.totalTokens)})
110
66
  </Reading>,
111
67
  )
@@ -113,21 +69,15 @@ export function TerminalStatusLine({
113
69
 
114
70
  if (window) {
115
71
  parts.push(
116
- <Reading
117
- key='usage'
118
- tone={SEVERITY_TONE[meterSeverity(window.info.utilization)]}
119
- onPress={onOpenUsage}
120
- label='Plan usage'>
72
+ <Reading key="usage" tone={SEVERITY_TONE[meterSeverity(window.info.utilization)]} onPress={onOpenUsage} label="Plan usage">
121
73
  {Math.round(window.info.utilization ?? 0)}% {windowLabel(window.key)}
122
74
  </Reading>,
123
75
  )
124
76
  }
125
77
 
126
- // Cost last of the numbers: it is the reading you check, never the one you act
127
- // on, and nothing about a turn changes because of it.
128
78
  if (state.totalCostUsd > 0) {
129
79
  parts.push(
130
- <Ink key='cost' tone='faint'>
80
+ <Ink key="cost" tone="faint">
131
81
  {formatCost(state.totalCostUsd)}
132
82
  </Ink>,
133
83
  )
@@ -135,24 +85,24 @@ export function TerminalStatusLine({
135
85
 
136
86
  if (model) {
137
87
  parts.push(
138
- <Ink key='model' tone='faint'>
88
+ <Ink key="model" tone="faint">
139
89
  {model}
140
90
  </Ink>,
141
91
  )
142
92
  }
143
93
  if (state.permissionMode && state.permissionMode !== 'default') {
144
94
  parts.push(
145
- <Ink key='mode' tone='yellow'>
95
+ <Ink key="mode" tone="yellow">
146
96
  {state.permissionMode}
147
97
  </Ink>,
148
98
  )
149
99
  }
150
100
 
151
101
  return (
152
- <Row data-slot='status-line' tone='dim' className={className}>
102
+ <Row data-slot="status-line" tone="dim" className={className}>
153
103
  {parts.map((part, index) => (
154
104
  <span key={index}>
155
- {index > 0 ? <Ink tone='faint'> · </Ink> : null}
105
+ {index > 0 ? <Ink tone="faint"> · </Ink> : null}
156
106
  {part}
157
107
  </span>
158
108
  ))}