A side pane with Git review, real PowerShell terminals and a live monitor of the servers running on localhost. Nothing else to install.

Small Claude Code mods, shipped as plugins from one marketplace.
| Plugin | What it does |
|---|---|
status-hud | A bar above the prompt: current activity, 5-hour limit, weekly limit and context window. |
Cache | A status-line countdown (Cache ⏱ 52:10) of the 1 hour prompt-cache window, counted from the last reply. |
workbench | A side pane (/workbench) with Git review, several real PowerShell terminals, and a live monitor of the servers listening on localhost. |
In Claude Code:
/plugin marketplace add hymleong/claude-mods
/plugin install status-hud@claude-mods
/plugin install Cache@claude-mods
/plugin install workbench@claude-mods
/reload-plugins
Each plugin installs and updates on its own. Mods need a recent Claude Code build that supports hooks modules.
/workbench opens the pane. It has three views:
cd into carry over between commands, and several sessions can run at once. Each command and its output is shown as a block with its exit status and how long it took. The input line has ↑/↓ history, Tab completion and Ctrl+C. Scripts found in the project (package.json, Makefile, justfile, Cargo, Go, compose, .NET, Django) run with one click./workbench also takes a view name (git, files, history, prs, actions, term, ports), new for a new terminal, or run <script>.
It needs only git and PowerShell: pwsh (PowerShell 7) is preferred, with Windows PowerShell as the fallback. There is no other app or service to install. Programs that wait for typed answers (Read-Host, password prompts, vim, ssh) cannot run in a pane terminal, because Claude Code gives plugins no way to send keystrokes to a running program.
Options, under /plugin → workbench: Shell (which PowerShell to run) and Load PowerShell profile.
Cache keeps its data on your machine only (in Claude Code's plugin store). It does not send anything anywhere.claude plugin marketplace update claude-mods, then claude plugin update <name>@claude-mods.MIT, see LICENSE.
hooks/register.tsx 1981 lines1import { atom, memberOf, read, update } from 'claude-code'
2import type { Elements, EngineInterface, HookStream, ProcessSpawnChunk, ProcessSpawnResult, Register, RenderSurface, Timer } from 'claude-code'
3
4import type {
5 Block,
6 Styled,
7 BranchPull,
8 Entry,
9 GitFile,
10 GitSection,
11 Hit,
12 Listing,
13 Mood,
14 OpenFile,
15 PortRow,
16 Ports,
17 PullList,
18 Repo,
19 Runs,
20 Script,
21 Search,
22 Tab,
23 Term,
24} from '../types'
25import type { EditorProps } from './editor'
26import {
27 LOG_ARGS,
28 PULL_FIELDS,
29 RUN_FIELDS,
30 commitArgs,
31 diffArgs,
32 fitHunks,
33 isChanged,
34 isSha,
35 isStaged,
36 moreLines,
37 parseCommit,
38 parseJobs,
39 parseLog,
40 parseNameStatus,
41 parsePull,
42 parsePulls,
43 parseRuns,
44 parseStatus,
45 runPrompt,
46 tail,
47} from './git'
48import { base, dir, isUnder, join, norm, rel, same } from './paths'
49import {
50 isProbed,
51 parseLsof,
52 parseNetstat,
53 parseProcessJson,
54 parsePs,
55 parseTasklist,
56 probeHost,
57 titleOf,
58 toRows,
59 withProbe,
60} from './ports'
61import type { Listener, Proc } from './ports'
62import { TASK_FILES, detect, isProjectDir } from './scripts'
63import { DONE_MS, OOPS_MS, moodOfTool } from './mood'
64import { HOST_BOOT, HOST_SCRIPT, processScript } from './shell-host'
65import { FRESH, feed, partial } from './stream'
66import { tabName, typedTitle, unique } from './tab-name'
67import type { StreamEvent, StreamState } from './stream'
68import type { InputProps } from './terminal-input'
69import { VIEW_LINES } from './views/files'
70import type { Actions, Kit, Model, SyncVerb } from './views/model'
71import { band, pane } from './views/pane'
72
73type Engine = EngineInterface
74
75// Every call on the engine lives in this file: the engine follows `$` into
76// functions declared in the hooks module and never across an import, and
77// `read`/`update` must name an atom declared here. What needs no engine is
78// in `git.ts`, `ports.ts`, `scripts.ts`, `stream.ts` and `views/`.
79
80const PANE = 'workbench'
81const TITLE = 'Workbench'
82const COLUMNS = 80
83const EDITOR = 'editor'
84const INPUT = 'input-'
85
86const GIT_EVERY_MS = 3_000
87const PORTS_EVERY_MS = 5_000
88const PROBE_EVERY_MS = 10_000
89const SLOW_EVERY_MS = 60_000
90const BEAT_EVERY_MS = 30_000
91const FLUSH_MS = 150
92const ROOTS_EVERY = 10
93const MAX_REPOS = 8
94const MAX_TERMS = 8
95/** Lines a block keeps in memory, and in the session's state (the open or latest block, and the others). */
96const KEPT_LINES = 5_000
97const STATE_LINES = 400
98const OLD_STATE_LINES = 40
99const STATE_BLOCKS = 12
100const MAX_LINE = 1_000
101const MAX_FILE_BYTES = 1_000_000
102/** The most of a file the editor takes: its text rides in the editor's props, which have a size limit. */
103const MAX_EDIT_CHARS = 90_000
104const MAX_FOUND = 200
105const HISTORY_KEPT = 200
106const HISTORY_KEY = 'history'
107/** How long a command runs before its tab takes its name: long enough that ls and cd never rename one. */
108const RENAME_AFTER_MS = 2_000
109const SECTIONS: readonly GitSection[] = ['changes', 'files', 'history', 'pulls', 'actions']
110const TABS: readonly Tab[] = ['git', 'term', 'ports']
111
112// ── what the pane draws from: the host keeps these for the session ─────────
113
114const isOn = atom({ plugin: 'workbench', key: 'isOn' } as const, false)
115const tab = atom({ plugin: 'workbench', key: 'tab' } as const, 'git')
116const scheme = atom({ plugin: 'workbench', key: 'scheme' } as const, 'dark')
117const mood = atom({ plugin: 'workbench', key: 'mood' } as const, 'sleep')
118const workspace = atom({ plugin: 'workbench', key: 'workspace' } as const, '')
119const counts = atom({ plugin: 'workbench', key: 'counts' } as const, { changed: 0, running: 0, ports: 0 })
120const repos = atom({ plugin: 'workbench', key: 'repos' } as const, [])
121const repo = atom({ plugin: 'workbench', key: 'repo' } as const, '')
122const gitSection = atom({ plugin: 'workbench', key: 'gitSection' } as const, 'changes')
123const diff = atom({ plugin: 'workbench', key: 'diff' } as const, null)
124const listing = atom({ plugin: 'workbench', key: 'listing' } as const, null)
125const file = atom({ plugin: 'workbench', key: 'file' } as const, null)
126const search = atom({ plugin: 'workbench', key: 'search' } as const, null)
127const history = atom({ plugin: 'workbench', key: 'history' } as const, null)
128const commit = atom({ plugin: 'workbench', key: 'commit' } as const, null)
129const pullList = atom({ plugin: 'workbench', key: 'pullList' } as const, null)
130const branchPull = atom({ plugin: 'workbench', key: 'branchPull' } as const, null)
131const runs = atom({ plugin: 'workbench', key: 'runs' } as const, null)
132const run = atom({ plugin: 'workbench', key: 'run' } as const, null)
133const terms = atom({ plugin: 'workbench', key: 'terms' } as const, [])
134const activeTerm = atom({ plugin: 'workbench', key: 'activeTerm' } as const, '')
135const screen = atom({ plugin: 'workbench', key: 'screen' } as const, [])
136const termHistory = atom({ plugin: 'workbench', key: 'termHistory' } as const, [])
137// `screen` and `termHistory` hold one value per terminal (memberOf by its id), so one terminal's output redraws only its own view.
138const blockOpen = atom({ plugin: 'workbench', key: 'blockOpen' } as const, '')
139const inputFill = atom({ plugin: 'workbench', key: 'inputFill' } as const, null)
140const scripts = atom({ plugin: 'workbench', key: 'scripts' } as const, [])
141const ports = atom({ plugin: 'workbench', key: 'ports' } as const, { rows: [], at: 0, note: '' })
142const portFilter = atom({ plugin: 'workbench', key: 'portFilter' } as const, 'dev')
143const portOpen = atom({ plugin: 'workbench', key: 'portOpen' } as const, '')
144const confirming = atom({ plugin: 'workbench', key: 'confirming' } as const, '')
145const busy = atom({ plugin: 'workbench', key: 'busy' } as const, '')
146
147
148// ── the module's own: work in flight, never what is drawn ───────────────────
149
150/** One PowerShell host as this module holds it. */
151type Live = {
152 id: string
153 token: string
154 port: number
155 pid: number
156 isSystem: boolean
157 stream?: HookStream<ProcessSpawnChunk, ProcessSpawnResult>
158 parse: StreamState
159 blocks: Block[]
160 /** The block whose output is arriving; `''` between commands. */
161 current: string
162 /** A command typed before the shell was up: sent once it is. */
163 pending: { id: string; cmd: string } | undefined
164 flushTimer: Timer | undefined
165 isClosed: boolean
166 isEnded: boolean
167 ready: Promise<boolean>
168 settle: (isUp: boolean) => void
169}
170
171let isWindows = true
172let home = ''
173let shellOption = ''
174let shellExe = ''
175let loadProfile = false
176const lives = new Map<string, Live>()
177let system: Live | undefined
178let timers: Timer[] = []
179let beat: Timer | undefined
180let isBegun = false
181let gitRun: Promise<void> | undefined
182let isGitQueued = false
183let portsRun: Promise<void> | undefined
184let extrasRun: Promise<void> | undefined
185let scriptsRun: Promise<void> | undefined
186let scriptsFor = ''
187let roots: string[] = []
188let rootsFor = ''
189let rootsAge = ROOTS_EVERY
190let recalledFor = ''
191let probedAt = 0
192let knownPorts: Set<string> | undefined
193let termColumns = 100
194let openBlockId = ''
195const readAt = new Map<string, number>()
196/** The input line's props as last drawn, by terminal: a reply to its Tab hands them back with the matches. */
197const inputDrawn = new Map<string, InputProps>()
198// The desktop app gives a pane the keyboard on the first click in it and
199// raises that click as a focus move only, never a press. Each button's press
200// is kept by key as it is drawn, so such a click can be answered as the press
201// it was, and the press the app may send after it is not run twice.
202let presses = new Map<string, () => void>()
203let isDesktopFocused: boolean | undefined
204let hasGrid = false
205let answered = { key: '', at: 0 }
206const ECHO_MS = 600
207
208const reasonOf = (error: unknown): string => (error instanceof Error ? error.message : String(error))
209const differs = (a: unknown, b: unknown): boolean => JSON.stringify(a) !== JSON.stringify(b)
210const newId = (): string => crypto.randomUUID().replace(/-/g, '').slice(0, 12)
211const obj = (value: unknown): Record<string, unknown> =>
212 typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
213const str = (value: unknown): string => (typeof value === 'string' ? value : '')
214const toast = ($: Engine, text: string, isBad = false): void => $.ui.toast(text, isBad ? { timeoutMs: 8_000 } : undefined)
215
216/** Whether `what` was read long enough ago to read again; saying yes marks it read now. */
217function isDue(what: string, force: boolean, every = SLOW_EVERY_MS): boolean {
218 const at = readAt.get(what)
219 if (!force && at !== undefined && Date.now() - at < every) return false
220 readAt.set(what, Date.now())
221
222 return true
223}
224
225/** A promise that settles with `fallback` after `ms`, its timer cancelled as soon as `work` settles. */
226function within<T, F>($: Engine, work: Promise<T>, ms: number, fallback: F): Promise<T | F> {
227 let timer: Timer | undefined
228 const timeout = new Promise<F>(resolve => {
229 timer = $.clock.after(ms, () => resolve(fallback))
230 })
231
232 return Promise.race([work, timeout]).finally(() => timer?.cancel())
233}
234
235// ── processes ───────────────────────────────────────────────────────────────
236
237type Ran = { code: number; out: string; err: string }
238
239async function exec($: Engine, argv: readonly string[], cwd?: string, timeoutMs = 20_000): Promise<Ran> {
240 try {
241 const ran = await $.process.run(argv, {
242 ...(cwd === undefined ? {} : { cwd }),
243 timeoutMs,
244 env: { GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '1', NO_COLOR: '1' },
245 })
246
247 return { code: ran.exitCode, out: ran.stdout, err: ran.stderr }
248 } catch (error) {
249 return { code: -1, out: '', err: reasonOf(error) }
250 }
251}
252
253const git = ($: Engine, cwd: string, args: readonly string[], timeoutMs = 20_000): Promise<Ran> =>
254 exec($, ['git', ...args], cwd, timeoutMs)
255
256/** A read from a timer must never hold the index lock: the person's own git, or Claude's, would fail on it. */
257const peek = ($: Engine, cwd: string, args: readonly string[]): Promise<Ran> => git($, cwd, ['--no-optional-locks', ...args])
258
259/** The text on the person's clipboard, `''` when there is none or it cannot be read. */
260async function readClipboard($: Engine): Promise<string> {
261 if (isWindows) {
262 // The system PowerShell answers fast; a fresh one is the fallback.
263 const host = await systemHost($)
264 if (host !== undefined) {
265 const answer = await call($, host, '/eval', { script: '[string](Get-Clipboard -Raw) | ConvertTo-Json -Compress' }, 5_000)
266 if (answer.ok) {
267 try {
268 const text: unknown = JSON.parse(answer.text)
269 if (typeof text === 'string') return text
270 } catch {
271 // Fall through to a fresh PowerShell.
272 }
273 }
274 }
275
276 return (await exec($, ['powershell', '-NoProfile', '-NonInteractive', '-Command', 'Get-Clipboard -Raw'], undefined, 8_000)).out.replace(/\r?\n$/, '')
277 }
278 const isMac = home.startsWith('/Users/')
279 const argv = isMac ? ['pbpaste'] : (await $.env.get('WAYLAND_DISPLAY')) ? ['wl-paste', '--no-newline'] : ['xclip', '-selection', 'clipboard', '-o']
280
281 return (await exec($, argv, undefined, 5_000)).out
282}
283
284/** Opens a web address in the person's browser. */
285async function browse($: Engine, url: string): Promise<boolean> {
286 if (!/^https?:\/\/[^\s"]+$/.test(url)) return false
287 const argv = isWindows ? ['rundll32', 'url.dll,FileProtocolHandler', url] : [(await $.env.get('WSL_DISTRO_NAME')) ? 'wslview' : 'xdg-open', url]
288 const isMac = !isWindows && home.startsWith('/Users/')
289 const ran = await exec($, isMac ? ['open', url] : argv, undefined, 10_000)
290
291 return ran.code === 0
292}
293
294// ── PowerShell hosts ────────────────────────────────────────────────────────
295
296/** The PowerShell to run: the person's choice, else pwsh, else Windows PowerShell. */
297async function resolveShell($: Engine): Promise<string> {
298 if (shellExe !== '') return shellExe
299 const candidates = shellOption !== '' ? [shellOption] : isWindows ? ['pwsh', 'powershell'] : ['pwsh']
300 for (const exe of candidates) {
301 const ran = await exec($, [exe, '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.Major'], undefined, 20_000)
302 if (ran.code === 0) {
303 shellExe = exe
304
305 return exe
306 }
307 }
308
309 return ''
310}
311
312function makeLive(id: string, isSystemHost: boolean, blocks: Block[] = []): Live {
313 let settle: (isUp: boolean) => void = () => undefined
314 const ready = new Promise<boolean>(resolve => {
315 settle = resolve
316 })
317
318 return {
319 id,
320 token: crypto.randomUUID(),
321 port: 0,
322 pid: 0,
323 isSystem: isSystemHost,
324 parse: FRESH,
325 blocks,
326 current: '',
327 pending: undefined,
328 flushTimer: undefined,
329 isClosed: false,
330 isEnded: false,
331 ready,
332 settle,
333 }
334}
335
336async function call(
337 $: Engine,
338 live: Live,
339 path: string,
340 body: unknown = {},
341 timeoutMs = 5_000,
342): Promise<{ ok: boolean; status: number; text: string }> {
343 if (live.port === 0) return { ok: false, status: 0, text: 'PowerShell is not up yet.' }
344 try {
345 const answer = await within(
346 $,
347 $.http.fetch(`http://127.0.0.1:${live.port}${path}`, {
348 method: 'POST',
349 headers: { 'x-wb-token': live.token, 'content-type': 'application/json' },
350 body: JSON.stringify(body),
351 }),
352 timeoutMs,
353 undefined,
354 )
355 if (answer === undefined) return { ok: false, status: 0, text: 'PowerShell did not answer in time.' }
356
357 return { ok: answer.status >= 200 && answer.status < 300, status: answer.status, text: answer.text }
358 } catch (error) {
359 return { ok: false, status: 0, text: reasonOf(error) }
360 }
361}
362
363/** Starts a host for `live` in `cwd` and reads what it writes until it exits. */
364async function startHost($: Engine, live: Live, cwd: string): Promise<void> {
365 const exe = await resolveShell($)
366 if (exe === '') {
367 live.settle(false)
368 await endLive($, live, 'PowerShell was not found. Install PowerShell 7 (https://aka.ms/powershell) or set the Shell option.')
369
370 return
371 }
372 try {
373 const stream = $.process.spawn({
374 argv: [exe, '-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', HOST_BOOT],
375 cwd,
376 env: {
377 WB_HOST: HOST_SCRIPT,
378 WB_TOKEN: live.token,
379 WB_CWD: cwd,
380 WB_PROFILE: loadProfile && !live.isSystem ? '1' : '0',
381 PYTHONUNBUFFERED: '1',
382 PYTHONIOENCODING: 'utf-8',
383 },
384 })
385 live.stream = stream
386 void (async () => {
387 let reason = 'PowerShell exited.'
388 try {
389 for await (const chunk of stream) hear($, live, chunk)
390 const ended = await stream.result.catch(() => undefined)
391 if (ended?.code !== null && ended?.code !== undefined && ended.code !== 0) reason = `PowerShell exited with code ${ended.code}.`
392 } catch (error) {
393 reason = `PowerShell could not start: ${reasonOf(error)}`
394 }
395 live.settle(false)
396 await endLive($, live, reason)
397 })()
398 } catch (error) {
399 live.settle(false)
400 await endLive($, live, `PowerShell could not start: ${reasonOf(error)}`)
401 }
402}
403
404/** One piece of what a host wrote. */
405function hear($: Engine, live: Live, chunk: ProcessSpawnChunk): void {
406 const fed = feed(live.parse, chunk.stream, chunk.text)
407 live.parse = fed.state
408 for (const event of fed.events) take($, live, event)
409 if (!live.isSystem) scheduleFlush($, live)
410}
411
412function blockFor(live: Live): Block {
413 const current = live.blocks.find(one => one.id === live.current)
414 if (current !== undefined) return current
415 const last = live.blocks[live.blocks.length - 1]
416 if (last !== undefined) return last
417 const shell: Block = { id: newId(), cmd: '', cwd: '', lines: [], dropped: 0, state: 'ok', exit: null, startedAt: Date.now(), endedAt: Date.now() }
418 live.blocks.push(shell)
419
420 return shell
421}
422
423function addLine(block: Block, text: string, tone: Block['lines'][number]['tone'], runs?: Styled[]): void {
424 const plain = text.length > MAX_LINE ? `${text.slice(0, MAX_LINE)}…` : text
425 // Coloured runs are kept to the same length as the plain line.
426 let room = MAX_LINE
427 const kept = runs?.flatMap(run => {
428 if (room <= 0) return []
429 const piece = run.text.slice(0, room)
430 room -= piece.length
431
432 return [{ ...run, text: piece }]
433 })
434 block.lines.push(kept === undefined ? { text: plain, tone } : { text: plain, tone, runs: kept })
435 if (block.lines.length > KEPT_LINES) {
436 const cut = block.lines.length - KEPT_LINES
437 block.lines.splice(0, cut)
438 block.dropped += cut
439 }
440}
441
442function take($: Engine, live: Live, event: StreamEvent): void {
443 if (event.kind === 'ready') {
444 live.port = event.port
445 live.pid = event.pid
446 live.settle(true)
447 if (live.isSystem) return
448 void setTerm($, live.id, term => ({ ...term, pid: event.pid, state: live.pending === undefined ? 'idle' : 'busy', note: '' }))
449 const waiting = live.pending
450 live.pending = undefined
451 if (waiting !== undefined) void send($, live, waiting.id, waiting.cmd)
452
453 return
454 }
455 if (live.isSystem) return
456 if (event.kind === 'start') {
457 const block = live.blocks.find(one => one.id === event.id)
458 if (block !== undefined) block.state = 'running'
459 live.current = event.id
460
461 return
462 }
463 if (event.kind === 'end') {
464 const block = live.blocks.find(one => one.id === event.id)
465 // What a progress line left unfinished is still output.
466 for (const left of partial(live.parse)) if (block !== undefined) addLine(block, left.text, left.tone, left.runs)
467 live.parse = FRESH
468 if (block !== undefined) {
469 block.state = event.state
470 block.exit = event.exit
471 block.endedAt = Date.now()
472 }
473 live.current = ''
474 void setTerm($, live.id, term => ({ ...term, state: 'idle', cwd: event.cwd === '' ? term.cwd : norm(event.cwd) }))
475 // A command can change files and free or take ports: look again soon.
476 void refreshGit($)
477 void refreshPorts($)
478
479 return
480 }
481 if (event.kind === 'info') {
482 if (event.text !== '') addLine(blockFor(live), event.text, 'info')
483
484 return
485 }
486 addLine(blockFor(live), event.text, event.tone, event.runs)
487}
488
489async function setTerm($: Engine, id: string, change: (term: Term) => Term): Promise<void> {
490 await update($, terms, list => list.map(one => (one.id === id ? change(one) : one)))
491 await recount($)
492}
493
494function scheduleFlush($: Engine, live: Live): void {
495 if (live.flushTimer !== undefined) return
496 live.flushTimer = $.clock.after(FLUSH_MS, () => {
497 live.flushTimer = undefined
498 void flush($, live)
499 })
500}
501
502/** Writes a terminal's blocks to the session's state, cut to what the view can show. */
503async function flush($: Engine, live: Live): Promise<void> {
504 const list = live.blocks.slice(-STATE_BLOCKS)
505 const snapshot = list.map((block, at) => {
506 const room = block.id === openBlockId || at >= list.length - 3 ? STATE_LINES : OLD_STATE_LINES
507 const lines = block.lines.slice(-room)
508 if (block.id === live.current) lines.push(...partial(live.parse))
509
510 return { ...block, lines, dropped: block.dropped + Math.max(0, block.lines.length - room) }
511 })
512 if (differs(await read($, memberOf(screen, { requestId: live.id })), snapshot)) await update($, memberOf(screen, { requestId: live.id }), () => snapshot)
513}
514
515/** The host is gone: its tab says so and keeps what it printed. */
516async function endLive($: Engine, live: Live, reason: string): Promise<void> {
517 if (live.isEnded) return
518 live.isEnded = true
519 if (live.isSystem) {
520 if (system === live) system = undefined
521
522 return
523 }
524 if (lives.get(live.id) === live) lives.delete(live.id)
525 if (live.isClosed) return
526 for (const block of live.blocks) {
527 if (block.state === 'running' || block.state === 'stopping') {
528 block.state = 'stopped'
529 block.endedAt = Date.now()
530 }
531 }
532 live.current = ''
533 await flush($, live)
534 await setTerm($, live.id, term => ({ ...term, state: 'ended', pid: 0, note: reason }))
535}
536
537/** The hidden host that answers the Ports view's questions about processes. */
538async function systemHost($: Engine): Promise<Live | undefined> {
539 if (system === undefined || system.isEnded) {
540 system = makeLive('system', true)
541 void startHost($, system, await $.session.cwd())
542 }
543 const held = system
544
545 return (await within($, held.ready, 15_000, false)) ? held : undefined
546}
547
548async function send($: Engine, live: Live, blockId: string, cmd: string): Promise<void> {
549 const sent = await call($, live, '/run', { id: blockId, cmd, width: Math.max(40, Math.min(250, termColumns)) })
550 if (sent.ok) return
551 const block = live.blocks.find(one => one.id === blockId)
552 if (block !== undefined) {
553 block.state = 'fail'
554 block.endedAt = Date.now()
555 addLine(block, sent.status === 409 ? 'Another command is still running.' : `Could not run it: ${sent.text}`, 'err')
556 }
557 live.current = ''
558 await flush($, live)
559 await setTerm($, live.id, term => ({ ...term, state: term.state === 'ended' ? 'ended' : 'idle' }))
560}
561
562/** The folder a new terminal opens in: the repository in view, else the session's. */
563async function startFolder($: Engine): Promise<string> {
564 const cwd = norm(await $.session.cwd())
565 const held = await read($, repos)
566 const picked = await read($, repo)
567 const inView = held.length === 1 ? held[0] : held.find(one => one.root === picked)
568
569 return inView?.root ?? cwd
570}
571
572async function recentCommands($: Engine): Promise<string[]> {
573 const kept = await $.store.get(HISTORY_KEY).catch(() => undefined)
574
575 return Array.isArray(kept) ? kept.filter((one): one is string => typeof one === 'string').slice(-HISTORY_KEPT) : []
576}
577
578async function newTerm($: Engine, options: { cwd?: string; title?: string; script?: string; cmd?: string } = {}): Promise<void> {
579 const list = await read($, terms)
580 if (list.length >= MAX_TERMS) {
581 toast($, `${MAX_TERMS} terminals is the most: close one first.`, true)
582
583 return
584 }
585 const cwd = norm(options.cwd ?? (await startFolder($)))
586 const id = newId()
587 let n = 1
588 while (list.some(one => one.title === `pwsh ${n}`)) n += 1
589 const term: Term = { id, title: options.title ?? `pwsh ${n}`, cwd, state: 'starting', pid: 0, script: options.script ?? '', note: '' }
590 await update($, terms, held => [...held, term])
591 await update($, memberOf(screen, { requestId: id }), () => [])
592 await update($, memberOf(termHistory, { requestId: id }), () => [])
593 await update($, activeTerm, () => id)
594 await update($, tab, () => 'term')
595 const recent = await recentCommands($)
596 if (recent.length > 0) await update($, memberOf(termHistory, { requestId: id }), () => recent)
597 const live = makeLive(id, false)
598 lives.set(id, live)
599 if (options.cmd !== undefined) await runLine($, id, options.cmd)
600 void startHost($, live, cwd)
601 await recount($)
602}
603
604async function remember($: Engine, id: string, cmd: string): Promise<void> {
605 const add = (list: readonly string[]): string[] => [...list.filter(one => one !== cmd), cmd].slice(-HISTORY_KEPT)
606 await update($, memberOf(termHistory, { requestId: id }), add)
607 const kept = await recentCommands($)
608 await $.store.set(HISTORY_KEY, add(kept)).catch(() => undefined)
609}
610
611async function runLine($: Engine, id: string, text: string): Promise<void> {
612 const cmd = text.replace(/\s+$/, '')
613 if (cmd.trim() === '') return
614 const word = cmd.trim().toLowerCase()
615 if (word === 'cls' || word === 'clear' || word === 'clear-host') return clearTerm($, id)
616 if (word === 'exit') return closeTerm($, id)
617 const named = typedTitle(cmd)
618 if (named !== undefined) return renameTerm($, id, named, cmd)
619 const term = (await read($, terms)).find(one => one.id === id)
620 const live = lives.get(id)
621 if (term === undefined) return
622 if (live === undefined || term.state === 'ended') {
623 toast($, 'This terminal has ended: restart it first.', true)
624
625 return
626 }
627 if (term.state === 'busy' || live.current !== '' || live.pending !== undefined) {
628 toast($, 'A command is still running here: stop it, or open another terminal.', true)
629
630 return
631 }
632 await remember($, id, cmd)
633 const block: Block = { id: newId(), cmd, cwd: term.cwd, lines: [], dropped: 0, state: 'running', exit: null, startedAt: Date.now(), endedAt: 0 }
634 live.blocks.push(block)
635 if (live.blocks.length > 200) live.blocks.splice(0, live.blocks.length - 200)
636 live.current = block.id
637 await setTerm($, id, one => ({ ...one, state: 'busy' }))
638 await flush($, live)
639 // Still running after a moment: a server, a watcher. The tab takes its name, as Windows Terminal's does.
640 $.clock.after(RENAME_AFTER_MS, () => {
641 if (live.current === block.id && !live.isEnded) void autoName($, id, cmd, term.cwd)
642 })
643 if (live.port === 0) {
644 live.pending = { id: block.id, cmd }
645
646 return
647 }
648 await send($, live, block.id, cmd)
649}
650
651/**
652 * The folder a command ran in, as a tab names it: `client` when that is a folder inside
653 * the repository or the session's folder, `''` at their top or outside them.
654 */
655async function subfolder($: Engine, cwd: string, cdTo: string | undefined): Promise<string> {
656 if (cdTo !== undefined) {
657 const target = cdTo.replace(/[\\/]+$/, '')
658 return target === '' || target === '.' || target === '..' || target.startsWith('~') ? '' : base(target)
659 }
660 const roots = [...(await read($, repos)).map(one => one.root), await read($, workspace)].filter(root => root !== '' && isUnder(cwd, root))
661 const root = roots.sort((a, b) => b.length - a.length)[0]
662
663 return root === undefined || same(root, cwd) ? '' : base(cwd)
664}
665
666/** Names a tab after the command that keeps running in it, unless the person named it. */
667async function autoName($: Engine, id: string, cmd: string, cwd: string): Promise<void> {
668 const term = (await read($, terms)).find(one => one.id === id)
669 if (term === undefined || term.isNamed === true) return
670 const found = tabName(cmd)
671 if (found.name === '') return
672 const folder = await subfolder($, cwd, found.cdTo)
673 const wanted = folder === '' ? found.name : `${folder}: ${found.name}`
674 // A tab already called this keeps its name; another running the same thing is numbered.
675 if (term.title === wanted) return
676 const taken = (await read($, terms)).filter(one => one.id !== id).map(one => one.title)
677 const title = unique(wanted, taken)
678 await setTerm($, id, one => (one.isNamed === true ? one : { ...one, title }))
679}
680
681/**
682 * The person named the tab (`title frontend`, `$Host.UI.RawUI.WindowTitle = "frontend"`):
683 * it keeps that name. `title` alone hands naming back to the commands. Echoed on the
684 * screen as a command, as the shell would have shown it.
685 */
686async function renameTerm($: Engine, id: string, name: string, cmd: string): Promise<void> {
687 const term = (await read($, terms)).find(one => one.id === id)
688 if (term === undefined) return
689 await remember($, id, cmd)
690 const title = name.slice(0, 40)
691 if (title === '') {
692 const list = await read($, terms)
693 let n = 1
694 while (list.some(one => one.id !== id && one.title === `pwsh ${n}`)) n += 1
695 await setTerm($, id, one => ({ ...one, title: `pwsh ${n}`, isNamed: false }))
696 } else {
697 await setTerm($, id, one => ({ ...one, title, isNamed: true }))
698 }
699 const live = lives.get(id)
700 if (live === undefined) return
701 const now = Date.now()
702 live.blocks.push({ id: newId(), cmd, cwd: term.cwd, lines: [], dropped: 0, state: 'ok', exit: null, startedAt: now, endedAt: now })
703 await flush($, live)
704}
705
706async function stopTerm($: Engine, id: string): Promise<void> {
707 const live = lives.get(id)
708 if (live === undefined) return
709 if (live.pending !== undefined) {
710 const waiting = live.pending
711 live.pending = undefined
712 const block = live.blocks.find(one => one.id === waiting.id)
713 if (block !== undefined) {
714 block.state = 'stopped'
715 block.endedAt = Date.now()
716 }
717 live.current = ''
718 await flush($, live)
719 await setTerm($, id, term => ({ ...term, state: term.state === 'busy' ? 'starting' : term.state }))
720
721 return
722 }
723 const block = live.blocks.find(one => one.id === live.current)
724 if (block !== undefined && block.state === 'running') block.state = 'stopping'
725 await flush($, live)
726 await call($, live, '/stop')
727}
728
729async function clearTerm($: Engine, id: string): Promise<void> {
730 const live = lives.get(id)
731 if (live !== undefined) live.blocks = live.blocks.filter(one => one.id === live.current)
732 await update($, memberOf(screen, { requestId: id }), list => list.filter(one => one.state === 'running' || one.state === 'stopping'))
733}
734
735/** Asks a host to leave; ends it by force if it has not gone within a few seconds. */
736function dismiss($: Engine, live: Live): void {
737 live.isClosed = true
738 if (live.flushTimer !== undefined) live.flushTimer.cancel()
739 void call($, live, '/exit', {}, 2_000).finally(() => {
740 $.clock.after(3_000, () => {
741 if (!live.isEnded) void live.stream?.return({ code: null, signal: null }).catch(() => undefined)
742 })
743 })
744}
745
746async function closeTerm($: Engine, id: string): Promise<void> {
747 const live = lives.get(id)
748 if (live !== undefined) {
749 lives.delete(id)
750 dismiss($, live)
751 }
752 const list = await read($, terms)
753 const at = list.findIndex(one => one.id === id)
754 const rest = list.filter(one => one.id !== id)
755 await update($, terms, () => rest)
756 await update($, memberOf(screen, { requestId: id }), () => [])
757 if ((await read($, activeTerm)) === id) await update($, activeTerm, () => rest[Math.min(Math.max(0, at), rest.length - 1)]?.id ?? '')
758 await recount($)
759}
760
761async function restartTerm($: Engine, id: string): Promise<void> {
762 const term = (await read($, terms)).find(one => one.id === id)
763 if (term === undefined) return
764 const old = lives.get(id)
765 const blocks = old?.blocks ?? (await read($, memberOf(screen, { requestId: id }))).map(block => ({ ...block, lines: [...block.lines] }))
766 if (old !== undefined) {
767 lives.delete(id)
768 dismiss($, old)
769 }
770 const notice: Block = {
771 id: newId(),
772 cmd: '',
773 cwd: term.cwd,
774 lines: [{ text: 'A new PowerShell session started here.', tone: 'info' }],
775 dropped: 0,
776 state: 'ok',
777 exit: null,
778 startedAt: Date.now(),
779 endedAt: Date.now(),
780 }
781 const live = makeLive(id, false, [...blocks, notice])
782 lives.set(id, live)
783 await setTerm($, id, one => ({ ...one, state: 'starting', pid: 0, note: '' }))
784 await flush($, live)
785 void startHost($, live, term.cwd)
786}
787
788/** The shell's completions for the word at `cursor`. */
789async function complete($: Engine, id: string, text: string, cursor: number): Promise<{ start: number; length: number; items: string[] }> {
790 const none = { start: cursor, length: 0, items: [] }
791 const live = lives.get(id)
792 if (live === undefined || text.length > 2_000) return none
793 const answer = await call($, live, '/complete', { text, cursor: Math.max(0, Math.min(text.length, cursor)) }, 4_000)
794 if (!answer.ok) return none
795 const said = obj(JSON.parse(answer.text) as unknown)
796 const items = Array.isArray(said.items) ? said.items.filter((one): one is string => typeof one === 'string') : typeof said.items === 'string' ? [said.items] : []
797
798 return {
799 start: typeof said.start === 'number' ? said.start : cursor,
800 length: typeof said.length === 'number' ? said.length : 0,
801 items: items.slice(0, 60),
802 }
803}
804
805/** Keeps every host alive while this session is: a host left alone for two minutes ends itself. */
806function heartbeat($: Engine): void {
807 for (const live of [...lives.values(), ...(system === undefined ? [] : [system])]) {
808 if (live.port > 0 && !live.isEnded) void call($, live, '/ping', {}, 3_000)
809 }
810}
811
812async function runScript($: Engine, script: Script): Promise<void> {
813 const list = await read($, terms)
814 const held = list.find(one => one.script === script.id && one.state !== 'ended')
815 if (held !== undefined) {
816 await update($, activeTerm, () => held.id)
817 await update($, tab, () => 'term')
818 if (held.state === 'busy') {
819 toast($, `${script.label} is already running in "${held.title}".`)
820
821 return
822 }
823 await runLine($, held.id, script.cmd)
824
825 return
826 }
827 await newTerm($, { cwd: script.cwd, title: script.label, script: script.id, cmd: script.cmd })
828}
829
830// What the mascot follows: whether a turn runs, how many subagents are out, and the
831// looks of the tools running now, the latest last.
832let inTurn = false
833let helpers = 0
834const acting: Mood[] = []
835let settle: Timer | undefined
836
837/** The mascot's look for what is happening now: a question first, then helpers, then the latest tool. */
838function moodNow(): Mood {
839 if (acting.includes('wait')) return 'wait'
840 if (helpers > 0) return 'helpers'
841
842 return acting[acting.length - 1] ?? (inTurn ? 'think' : 'sleep')
843}
844
845/** Sets what the mascot is doing; writes only a change, so the header redraws once. */
846async function setMood($: Engine, next: Mood): Promise<void> {
847 if ((await read($, mood)) !== next) await update($, mood, () => next)
848}
849
850async function recount($: Engine): Promise<void> {
851 const running = (await read($, terms)).filter(one => one.state === 'busy').length
852 if ((await read($, counts)).running !== running) await update($, counts, held => ({ ...held, running }))
853}
854
855// ── ports ───────────────────────────────────────────────────────────────────
856
857async function probe($: Engine, row: PortRow): Promise<PortRow> {
858 const started = Date.now()
859 try {
860 const answer = await within(
861 $,
862 $.http.fetch(`http://${probeHost(row.addresses)}:${row.port}/`, { headers: { 'user-agent': 'workbench-probe', accept: 'text/html,*/*' } }),
863 2_500,
864 undefined,
865 )
866 if (answer === undefined) return withProbe(row, { status: 0, title: '', ms: 0, server: '' })
867 const header = (name: string): string => answer.headers[name] ?? answer.headers[name.toLowerCase()] ?? ''
868
869 return withProbe(
870 row,
871 { status: answer.status, title: titleOf(answer.text.slice(0, 50_000)), ms: Date.now() - started, server: header('server') },
872 { server: header('server'), poweredBy: header('x-powered-by'), body: answer.text.slice(0, 50_000) },
873 )
874 } catch {
875 // Not HTTP: the port takes connections and says something else, or nothing.
876 return withProbe(row, null)
877 }
878}
879
880async function loadPorts($: Engine, force: boolean): Promise<void> {
881 let listeners: Listener[] = []
882 let procs: Map<number, Proc> = new Map()
883 let note = ''
884 if (isWindows) {
885 const listed = await exec($, ['netstat', '-ano'], undefined, 15_000)
886 if (listed.code !== 0) note = `netstat did not answer: ${tail(listed.err || listed.out, 1)}`
887 listeners = parseNetstat(listed.out)
888 const host = await systemHost($)
889 const pids = [...new Set(listeners.map(one => one.pid))]
890 if (host !== undefined) {
891 const answer = await call($, host, '/eval', { script: processScript(pids) }, 10_000)
892 if (answer.ok) procs = parseProcessJson(answer.text)
893 }
894 if (procs.size === 0) procs = parseTasklist((await exec($, ['tasklist', '/fo', 'csv', '/nh'], undefined, 15_000)).out)
895 } else {
896 const listed = await exec($, ['lsof', '-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pcn'], undefined, 15_000)
897 if (listed.code !== 0 && listed.out === '') note = 'lsof is needed to list ports on this system.'
898 listeners = parseLsof(listed.out)
899 procs = parsePs((await exec($, ['ps', '-axo', 'pid=,ppid=,rss=,command='], undefined, 15_000)).out)
900 }
901 // The hosts' own control ports are the workbench's, not the person's.
902 const hostPorts = new Set([...lives.values(), ...(system === undefined ? [] : [system])].map(live => `${live.pid}:${live.port}`))
903 listeners = listeners.filter(one => !hostPorts.has(`${one.pid}:${one.port}`))
904 const hosts = new Map([...lives.values()].filter(live => live.pid > 0).map(live => [live.pid, live.id] as const))
905 const before = await read($, ports)
906 let rows = toRows(listeners, procs, hosts, before.rows)
907 if (force || Date.now() - probedAt >= PROBE_EVERY_MS) {
908 probedAt = Date.now()
909 rows = await Promise.all(rows.map(row => (isProbed(row) ? probe($, row) : Promise.resolve(row))))
910 }
911 const next: Ports = { rows, at: Date.now(), note }
912 await update($, ports, () => next)
913 const dev = rows.filter(row => row.kind === 'dev')
914 if ((await read($, counts)).ports !== dev.length) await update($, counts, held => ({ ...held, ports: dev.length }))
915 // Say what came up and what went away, once the first look has said what was already there.
916 const now = new Set(dev.map(row => `${row.port}:${row.name}`))
917 if (knownPorts !== undefined) {
918 const known = knownPorts
919 const up = dev.filter(row => !known.has(`${row.port}:${row.name}`))
920 const gone = [...known].filter(key => !now.has(key))
921 if (up.length > 0) toast($, `Listening: ${up.map(row => `:${row.port} ${row.name}${row.framework === '' ? '' : ` (${row.framework})`}`).join(', ')}`)
922 else if (gone.length > 0) toast($, `Stopped listening: ${gone.map(key => `:${key.split(':')[0]}`).join(', ')}`)
923 }
924 knownPorts = now
925}
926
927function refreshPorts($: Engine, force = false): Promise<void> {
928 portsRun ??= (async () => {
929 try {
930 await loadPorts($, force)
931 } catch {
932 // The next poll is the retry.
933 } finally {
934 portsRun = undefined
935 }
936 })()
937
938 return portsRun
939}
940
941async function killPort($: Engine, key: string): Promise<void> {
942 const row = (await read($, ports)).rows.find(one => one.key === key)
943 await update($, confirming, () => '')
944 if (row === undefined || row.kind === 'system' || row.pid <= 4) return
945 const argv = isWindows ? ['taskkill', '/PID', String(row.pid), '/T', '/F'] : ['kill', '-TERM', String(row.pid)]
946 const ran = await exec($, argv, undefined, 15_000)
947 toast($, ran.code === 0 ? `Ended ${row.name} (pid ${row.pid}).` : `Could not end ${row.name}: ${tail(ran.err || ran.out, 2)}`, ran.code !== 0)
948 await refreshPorts($, true)
949}
950
951// ── git ─────────────────────────────────────────────────────────────────────
952
953/** The repositories a folder stands for: the one it is in, or each child that holds a `.git`. */
954async function findRepos($: Engine, cwd: string): Promise<string[]> {
955 const top = await peek($, cwd, ['rev-parse', '--show-toplevel'])
956 if (top.code === 0 && top.out.trim() !== '') return [norm(top.out.trim())]
957 const found: string[] = []
958 try {
959 for (const entry of await $.fs.list(cwd)) {
960 if (entry.kind !== 'dir' || !isProjectDir(entry.name)) continue
961 if (await $.fs.exists(join(cwd, entry.name, '.git'))) found.push(join(cwd, entry.name))
962 if (found.length >= MAX_REPOS) break
963 }
964 } catch {
965 return []
966 }
967
968 return found.sort()
969}
970
971async function readRepo($: Engine, root: string): Promise<Repo | null> {
972 const ran = await peek($, root, ['status', '--porcelain=v1', '--branch', '-z', '--untracked-files=all'])
973 if (ran.code !== 0) return null
974
975 return { root, name: base(root), ...parseStatus(ran.out) }
976}
977
978async function readDiff($: Engine, root: string, gitFile: GitFile, staged: boolean, max?: number) {
979 const out = (await peek($, root, diffArgs(gitFile, staged))).out
980
981 return { root, path: gitFile.path, isStaged: staged, ...fitHunks(out, max) }
982}
983
984const pickKey = (cwd: string): string => `repo:${norm(cwd).toLowerCase()}`
985
986async function loadGit($: Engine): Promise<void> {
987 const cwd = norm(await $.session.cwd())
988 if ((await read($, workspace)) !== cwd) await update($, workspace, () => cwd)
989 if (rootsAge >= ROOTS_EVERY || rootsFor !== cwd) {
990 roots = await findRepos($, cwd)
991 rootsFor = cwd
992 rootsAge = 0
993 } else {
994 rootsAge += 1
995 }
996 const loaded = (await Promise.all(roots.map(root => readRepo($, root)))).filter((one): one is Repo => one !== null)
997 if (differs(await read($, repos), loaded)) await update($, repos, () => loaded)
998 // A folder of several repositories opens on the one picked there last time.
999 if (recalledFor !== cwd) {
1000 recalledFor = cwd
1001 const kept = await $.store.get(pickKey(cwd)).catch(() => undefined)
1002 if (typeof kept === 'string' && (await read($, repo)) === '' && loaded.some(one => one.root === kept)) await update($, repo, () => kept)
1003 }
1004 const changed = loaded.reduce((sum, one) => sum + one.files.length, 0)
1005 if ((await read($, counts)).changed !== changed) await update($, counts, held => ({ ...held, changed }))
1006 // The diff on screen follows its file, and closes once the file has left that side.
1007 const open = await read($, diff)
1008 if (open !== null) {
1009 const held = loaded.find(one => one.root === open.root)?.files.find(one => one.path === open.path && (open.isStaged ? isStaged(one) : isChanged(one)))
1010 const fresh = held === undefined ? null : await readDiff($, open.root, held, open.isStaged, Math.max(400, open.shown))
1011 if (differs(await read($, diff), fresh)) await update($, diff, () => fresh)
1012 }
1013 void refreshScripts($)
1014}
1015
1016function refreshGit($: Engine): Promise<void> {
1017 if (gitRun !== undefined) {
1018 isGitQueued = true
1019
1020 return gitRun
1021 }
1022 gitRun = (async () => {
1023 try {
1024 do {
1025 isGitQueued = false
1026 await loadGit($)
1027 } while (isGitQueued)
1028 } catch {
1029 // The next poll is the retry.
1030 } finally {
1031 gitRun = undefined
1032 }
1033 })()
1034
1035 return gitRun
1036}
1037
1038async function inViewRepo($: Engine): Promise<Repo | undefined> {
1039 const held = await read($, repos)
1040 const picked = await read($, repo)
1041
1042 return held.length === 1 ? held[0] : held.find(one => one.root === picked)
1043}
1044
1045async function readPullList($: Engine, root: string): Promise<PullList> {
1046 const ran = await exec($, ['gh', 'pr', 'list', '--state', 'open', '--limit', '30', '--json', PULL_FIELDS], root, 25_000)
1047 if (ran.code === 0) return { root, items: parsePulls(ran.out), note: '' }
1048
1049 return { root, items: [], note: tail(ran.err || ran.out, 2) || 'gh could not list the pull requests.' }
1050}
1051
1052async function readBranchPull($: Engine, root: string, branch: string): Promise<BranchPull> {
1053 const ran = await exec($, ['gh', 'pr', 'view', '--json', PULL_FIELDS], root, 25_000)
1054
1055 return { root, branch, pull: ran.code === 0 ? parsePull(ran.out) : null }
1056}
1057
1058async function readRuns($: Engine, root: string, branch: string): Promise<Runs> {
1059 const ran = await exec($, ['gh', 'run', 'list', '--branch', branch, '--limit', '15', '--json', RUN_FIELDS], root, 25_000)
1060 if (ran.code === 0) return { root, branch, items: parseRuns(ran.out), note: '' }
1061
1062 return { root, branch, items: [], note: tail(ran.err || ran.out, 2) || 'gh could not list the runs.' }
1063}
1064
1065async function readJobs($: Engine, root: string, id: number) {
1066 const ran = await exec($, ['gh', 'run', 'view', String(id), '--json', 'jobs'], root, 25_000)
1067
1068 return ran.code === 0 ? parseJobs(ran.out) : []
1069}
1070
1071/** The reads only one view shows, for the view in front, no oftener than a minute apart. */
1072async function loadExtras($: Engine, force: boolean): Promise<void> {
1073 if ((await read($, tab)) !== 'git') return
1074 const part = await read($, gitSection)
1075 const inView = await inViewRepo($)
1076 if (part === 'files') {
1077 const open = await read($, file)
1078 if (open === null) {
1079 const found = await listDir($, (await read($, listing))?.path ?? (await filesRoot($)))
1080 if (found !== null && differs(await read($, listing), found)) await update($, listing, () => found)
1081 } else if (!open.isEditing && open.note === '') {
1082 // Someone else wrote the file (Claude, an editor): what is shown follows.
1083 const mtimeMs = await $.fs.stat(open.path).then(stat => stat.mtimeMs).catch(() => 0)
1084 if (mtimeMs !== open.mtimeMs) {
1085 const fresh = await readFile($, open.path)
1086 await update($, file, now => (now === null || now.path !== fresh.path ? now : { ...fresh, top: now.top, isEditing: now.isEditing }))
1087 }
1088 }
1089 }
1090 if (inView === undefined) return
1091 if (part === 'history' && isDue(`log:${inView.root}`, force, 15_000)) {
1092 const found = { root: inView.root, rows: parseLog((await peek($, inView.root, LOG_ARGS)).out) }
1093 if (differs(await read($, history), found)) await update($, history, () => found)
1094 }
1095 if (part === 'pulls' && isDue(`pulls:${inView.root}`, force)) {
1096 const found = await readPullList($, inView.root)
1097 if (differs(await read($, pullList), found)) await update($, pullList, () => found)
1098 }
1099 if (part === 'changes' && inView.upstream !== '' && isDue(`pull:${inView.root}:${inView.branch}`, force)) {
1100 const found = await readBranchPull($, inView.root, inView.branch)
1101 if (differs(await read($, branchPull), found)) await update($, branchPull, () => found)
1102 }
1103 if (part === 'actions' && isDue(`runs:${inView.root}:${inView.branch}`, force)) {
1104 const found = await readRuns($, inView.root, inView.branch)
1105 if (differs(await read($, runs), found)) await update($, runs, () => found)
1106 const open = await read($, run)
1107 if (open !== null && found.items.some(one => one.id === open.id)) {
1108 const fresh = { id: open.id, jobs: await readJobs($, inView.root, open.id) }
1109 if (differs(await read($, run), fresh)) await update($, run, () => fresh)
1110 }
1111 }
1112}
1113
1114function refreshExtras($: Engine, force = false): Promise<void> {
1115 extrasRun ??= (async () => {
1116 try {
1117 await loadExtras($, force)
1118 } catch {
1119 // The next poll is the retry.
1120 } finally {
1121 extrasRun = undefined
1122 }
1123 })()
1124
1125 return extrasRun
1126}
1127
1128async function reloadExtras($: Engine): Promise<void> {
1129 if (extrasRun !== undefined) await extrasRun
1130 await refreshExtras($, true)
1131}
1132
1133/** The scripts of the repository in view (or the session's folder), and of the folders right under it. */
1134async function loadScripts($: Engine): Promise<void> {
1135 const root = await startFolder($)
1136 if (root === scriptsFor) return
1137 scriptsFor = root
1138 const readFolder = async (path: string): Promise<Script[]> => {
1139 try {
1140 const entries = await $.fs.list(path)
1141 const names = entries.map(one => one.name)
1142 const files: Record<string, string> = {}
1143 for (const name of TASK_FILES) {
1144 if (names.includes(name)) files[name] = await $.fs.read(join(path, name)).catch(() => '')
1145 }
1146
1147 return detect({ path, rel: rel(path, root), names, files })
1148 } catch {
1149 return []
1150 }
1151 }
1152 const found = await readFolder(root)
1153 try {
1154 const children = (await $.fs.list(root)).filter(one => one.kind === 'dir' && isProjectDir(one.name)).slice(0, 40)
1155 let added = 0
1156 for (const child of children) {
1157 const names = (await $.fs.list(join(root, child.name)).catch(() => [])).map(one => one.name)
1158 if (!names.some(name => (TASK_FILES as readonly string[]).includes(name) || name === 'Cargo.toml' || name === 'go.mod')) continue
1159 found.push(...(await readFolder(join(root, child.name))))
1160 added += 1
1161 if (added >= 8) break
1162 }
1163 } catch {
1164 // The root alone, then.
1165 }
1166 if (differs(await read($, scripts), found)) await update($, scripts, () => found)
1167}
1168
1169function refreshScripts($: Engine): Promise<void> {
1170 scriptsRun ??= (async () => {
1171 try {
1172 await loadScripts($)
1173 } catch {
1174 scriptsFor = ''
1175 } finally {
1176 scriptsRun = undefined
1177 }
1178 })()
1179
1180 return scriptsRun
1181}
1182
1183// ── files ───────────────────────────────────────────────────────────────────
1184
1185async function filesRoot($: Engine): Promise<string> {
1186 return (await inViewRepo($))?.root ?? norm(await $.session.cwd())
1187}
1188
1189async function listDir($: Engine, path: string): Promise<Listing | null> {
1190 const root = await filesRoot($)
1191 const at = norm(path)
1192 if (!isUnder(at, root) && !isUnder(at, norm(await $.session.cwd()))) return null
1193 try {
1194 const entries: Entry[] = (await $.fs.list(at))
1195 .filter(one => one.name !== '.git' && one.kind !== 'other')
1196 .map(one => ({ name: one.name, isDir: one.kind === 'dir', size: one.size }))
1197 .sort((a, b) => (a.isDir === b.isDir ? a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) : a.isDir ? -1 : 1))
1198
1199 return { path: at, entries }
1200 } catch {hooks/editor.tsx 138 lines1import type { ClientModule } from 'claude-code'
2
3import { bufferOf, edit, keepSpaces, scrolled, textOf } from './edit-buffer'
4import type { Buffer } from './edit-buffer'
5
6// The file editor: a surface module, so keys reach it as they are pressed.
7// Plain keys, as in any editor: type, move with the arrows, ctrl+s to save,
8// ctrl+z to undo. Saving posts the text to the hooks module, which writes
9// the file only if nobody else changed it since it was opened.
10
11export type EditorProps = {
12 path: string
13 text: string
14 /** When the file was last written as the hooks module read it: a new one is new text on disk. */
15 rev: number
16 /** Rows the editor may take; 0 when unknown. */
17 rows: number
18 isProportional: boolean
19 accent: string
20}
21
22type State = {
23 rev: number
24 buffer: Buffer
25 top: number
26 isDirty: boolean
27 undo: Buffer[]
28 hasKeys: boolean
29}
30
31const MAX_UNDO = 200
32const MIN_ROWS = 8
33const DEFAULT_ROWS = 24
34
35// What each instance last set, kept here as well: keys can arrive faster than frames,
36// and each must edit what the one before it left, not what was last drawn.
37const latest = new WeakMap<object, State>()
38
39const Editor: ClientModule<EditorProps, State> = (props, surface) => {
40 const put = (next: State): void => {
41 latest.set(surface, next)
42 surface.setState(next)
43 }
44 const { Box, Text, Button } = surface.elements
45 const held = latest.get(surface) ?? surface.state
46 // New text on disk replaces the buffer unless there are unsaved changes; text equal
47 // to the buffer is this editor's own save coming back.
48 const isFresh = held === undefined || (held.rev !== props.rev && (!held.isDirty || textOf(held.buffer) === props.text))
49 const state: State = isFresh
50 ? {
51 rev: props.rev,
52 buffer: bufferOf(props.text, held?.buffer.row ?? 0, held?.buffer.col ?? 0),
53 top: held?.top ?? 0,
54 isDirty: false,
55 undo: [],
56 hasKeys: held?.hasKeys ?? false,
57 }
58 : held
59 if (isFresh) put(state)
60
61 const page = Math.max(MIN_ROWS, (props.rows > 0 ? props.rows : surface.rows > 0 ? surface.rows : DEFAULT_ROWS) - 2)
62 const { buffer } = state
63 const digits = String(buffer.lines.length).length
64
65 // Keys can come faster than frames: each one edits what the last one left, not what was last drawn.
66 const now = (): State => latest.get(surface) ?? state
67 const save = (): void => {
68 const held = now()
69 surface.post({ type: 'save', path: props.path, text: textOf(held.buffer), rev: held.rev })
70 }
71 const undo = (): void => {
72 const held = now()
73 const before = held.undo[held.undo.length - 1]
74 if (before !== undefined) {
75 put({ ...held, buffer: before, undo: held.undo.slice(0, -1), isDirty: true, top: scrolled(held.top, before.row, page) })
76 }
77 }
78
79 surface.onKey(key => {
80 if (key.ctrl === true && key.key === 's') return save()
81 if (key.ctrl === true && key.key === 'z') return undo()
82 const held = now()
83 const next = edit(held.buffer, key, true, page)
84 if (next === undefined) return
85 const changed = next.lines !== held.buffer.lines
86 put({
87 ...held,
88 hasKeys: true,
89 buffer: next,
90 top: scrolled(held.top, next.row, page),
91 isDirty: held.isDirty || changed,
92 undo: changed ? [...held.undo, held.buffer].slice(-MAX_UNDO) : held.undo,
93 })
94 })
95 surface.onPointer(event => {
96 if (event.type !== 'down') return
97 const held = now()
98 const row = Math.max(0, Math.min(held.buffer.lines.length - 1, held.top + Math.floor(event.y)))
99 const col = Math.max(0, Math.min((held.buffer.lines[row] ?? '').length, Math.floor(event.x) - digits - 1))
100 put({ ...held, hasKeys: true, buffer: { lines: held.buffer.lines, row, col } })
101 })
102
103 const show = (text: string): string => (props.isProportional ? keepSpaces(text) : text)
104
105 return (
106 <Box flexDirection="column">
107 {buffer.lines.slice(state.top, state.top + page).map((line, at) => {
108 const row = state.top + at
109 const isHere = row === buffer.row && state.hasKeys
110
111 return (
112 <Box key={`l${row}`} flexDirection="row">
113 <Text dimColor={row !== buffer.row} color={row === buffer.row ? props.accent : undefined}>
114 {`${String(row + 1).padStart(digits)} `}
115 </Text>
116 {isHere && <Text>{show(line.slice(0, buffer.col))}</Text>}
117 {isHere && <Text inverse>{show(line[buffer.col] ?? ' ')}</Text>}
118 {isHere && <Text>{show(line.slice(buffer.col + 1))}</Text>}
119 {!isHere && <Text>{show(line === '' ? ' ' : line)}</Text>}
120 </Box>
121 )
122 })}
123 <Box flexDirection="row" marginTop={1} columnGap={2}>
124 <Text color={state.isDirty ? props.accent : undefined} dimColor={!state.isDirty}>
125 {state.isDirty ? '● unsaved' : 'saved'}
126 </Text>
127 <Text dimColor>{`Ln ${buffer.row + 1}, Col ${buffer.col + 1}`}</Text>
128 <Box flexGrow={1} />
129 {!state.hasKeys && <Text dimColor>click in the text to type</Text>}
130 {state.undo.length > 0 && <Button key="undo" plain dimColor label="undo" onPress={() => undo()} />}
131 {state.isDirty && <Button key="save" plain label="save · ctrl+s" onPress={() => save()} />}
132 </Box>
133 </Box>
134 )
135}
136
137export default Editor
138hooks/git.ts 385 lines1import type { Checks, CommitFile, GitFile, Job, LogRow, Pull, Run } from '../types'
2
3// git's and gh's output, read. The hooks module runs the commands; nothing
4// here touches the engine, so all of it can be tested on fixtures.
5
6export type Status = {
7 branch: string
8 upstream: string
9 ahead: number
10 behind: number
11 isDetached: boolean
12 files: GitFile[]
13}
14
15/** `git status --porcelain=v1 --branch -z`. */
16export function parseStatus(out: string): Status {
17 const entries = out.split('\0')
18 const status: Status = { branch: '', upstream: '', ahead: 0, behind: 0, isDetached: false, files: [] }
19 let at = 0
20 const head = entries[0] ?? ''
21 if (head.startsWith('## ')) {
22 at = 1
23 let rest = head.slice(3)
24 const bracket = /\s\[([^\]]*)\]$/.exec(rest)
25 if (bracket !== null) {
26 rest = rest.slice(0, bracket.index)
27 status.ahead = Number(/ahead (\d+)/.exec(bracket[1] ?? '')?.[1] ?? 0)
28 status.behind = Number(/behind (\d+)/.exec(bracket[1] ?? '')?.[1] ?? 0)
29 }
30 if (rest.startsWith('HEAD (no branch)')) {
31 status.isDetached = true
32 status.branch = 'HEAD'
33 } else {
34 const named = rest.replace(/^(No commits yet on |Initial commit on )/, '')
35 const [branch, upstream] = named.split('...')
36 status.branch = branch ?? ''
37 status.upstream = upstream ?? ''
38 }
39 }
40 for (; at < entries.length; at += 1) {
41 const entry = entries[at] ?? ''
42 if (entry.length < 4) continue
43 const index = entry[0] ?? ' '
44 const tree = entry[1] ?? ' '
45 const path = entry.slice(3)
46 let from = ''
47 if (index === 'R' || index === 'C' || tree === 'R' || tree === 'C') {
48 at += 1
49 from = entries[at] ?? ''
50 }
51 status.files.push({ path, from, index, tree })
52 }
53
54 return status
55}
56
57export const isUntracked = (file: GitFile): boolean => file.index === '?' && file.tree === '?'
58export const isStaged = (file: GitFile): boolean => !' ?!'.includes(file.index)
59export const isChanged = (file: GitFile): boolean => file.tree !== ' ' && file.tree !== '!'
60export const isConflict = (file: GitFile): boolean =>
61 file.index === 'U' || file.tree === 'U' || (file.index === 'A' && file.tree === 'A') || (file.index === 'D' && file.tree === 'D')
62
63/** The letter that best says what happened to a file on one side. */
64export function letterOf(file: GitFile, staged: boolean): string {
65 if (isUntracked(file)) return 'U'
66 if (isConflict(file)) return '!'
67
68 return (staged ? file.index : file.tree).trim() || 'M'
69}
70
71/** The diff of one file on one side; untracked files against nothing. */
72export function diffArgs(file: GitFile, staged: boolean): string[] {
73 const base = ['diff', '--no-color', '--no-ext-diff']
74 if (staged) return [...base, '--cached', '-M', '--', ...(file.from === '' ? [] : [file.from]), file.path]
75 if (isUntracked(file)) return [...base, '--no-index', '--', '/dev/null', file.path]
76
77 return [...base, '--', file.path]
78}
79
80export type Fitted = { text: string; shown: number; total: number; isBinary: boolean }
81
82/** A hunk's header with its line counts set to what its body now holds. */
83function recount(header: string, body: readonly string[]): string {
84 const old = body.filter(line => line.startsWith(' ') || line.startsWith('-')).length
85 const now = body.filter(line => line.startsWith(' ') || line.startsWith('+')).length
86
87 return header.replace(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/, (_, a: string, c: string) => `@@ -${a},${old} +${c},${now} @@`)
88}
89
90/**
91 * A unified diff cut to about `max` lines without breaking a hunk: whole
92 * hunks while they fit, and a first hunk longer than that cut short with its
93 * header recounted, so the result still parses as a diff.
94 */
95export function fitHunks(out: string, max = 400): Fitted {
96 if (/^Binary files .* differ$/m.test(out)) return { text: '', shown: 0, total: 0, isBinary: true }
97 const lines = out.replace(/\r\n/g, '\n').split('\n')
98 const hunks: string[][] = []
99 for (const line of lines) {
100 if (line.startsWith('@@')) hunks.push([line])
101 else if (hunks.length > 0 && (line.startsWith(' ') || line.startsWith('+') || line.startsWith('-') || line.startsWith('\\'))) {
102 hunks[hunks.length - 1]!.push(line)
103 }
104 }
105 const total = hunks.reduce((sum, hunk) => sum + hunk.length, 0)
106 const kept: string[] = []
107 for (const hunk of hunks) {
108 if (kept.length + hunk.length <= max) {
109 kept.push(...hunk)
110 continue
111 }
112 if (kept.length === 0) {
113 const body = hunk.slice(1, Math.max(2, max))
114 kept.push(recount(hunk[0]!, body), ...body)
115 }
116 break
117 }
118
119 return { text: kept.join('\n'), shown: kept.length, total, isBinary: false }
120}
121
122/** The next size a diff grows to when the person asks for more. */
123export const moreLines = (shown: number): number => Math.max(800, shown * 2)
124
125const SEP = '\u001f'
126
127export const LOG_ARGS = [
128 'log',
129 '--graph',
130 '--all',
131 '--color=never',
132 '--date=relative',
133 `--format=${SEP}%h${SEP}%p${SEP}%D${SEP}%s${SEP}%an${SEP}%ad`,
134 '-n',
135 '300',
136] as const
137
138/** `git log --graph` in LOG_ARGS's format: a row per line, graph-only lines with no commit. */
139export function parseLog(out: string): LogRow[] {
140 return out
141 .replace(/\r\n/g, '\n')
142 .split('\n')
143 .filter(line => line !== '')
144 .map(line => {
145 const cut = line.indexOf(SEP)
146 if (cut < 0) return { graph: line.trimEnd(), sha: '', parents: [], refs: '', subject: '', author: '', when: '' }
147 const [sha = '', parents = '', refs = '', subject = '', author = '', when = ''] = line.slice(cut + 1).split(SEP)
148
149 return { graph: line.slice(0, cut).trimEnd(), sha, parents: parents.split(' ').filter(one => one !== ''), refs, subject, author, when }
150 })
151}
152
153/** One commit's place in the drawn graph: its lane, and the lines through, into and out of its row. */
154export type GraphRow = {
155 lane: number
156 colour: number
157 /** The commit's lane comes down from the row above. */
158 isFromAbove: boolean
159 /** Its first parent goes on down its lane. */
160 isToBelow: boolean
161 /** Lanes passing straight through the row, with their colours. */
162 through: { lane: number; colour: number }[]
163 /** Lanes from above that end in this commit (other children's lines joining it). */
164 joins: { lane: number; colour: number }[]
165 /** Lanes this commit's other parents leave by, down to the next row. */
166 forks: { lane: number; colour: number }[]
167}
168
169/**
170 * Lays the commits out in lanes from their parents, newest first, as a graph
171 * viewer does: a commit sits in the lane that was waiting for it, its first
172 * parent goes on in that lane, each other parent takes a lane of its own (or
173 * joins the one already waiting for it), and freed lanes are used again.
174 * Lanes keep their places, so lines never shift sideways between rows.
175 */
176export function layoutGraph(rows: readonly Pick<LogRow, 'sha' | 'parents'>[]): { rows: GraphRow[]; lanes: number } {
177 const waiting: (string | null)[] = []
178 const colours: number[] = []
179 let nextColour = 0
180 let widest = 1
181 const free = (): number => {
182 const at = waiting.indexOf(null)
183
184 return at < 0 ? waiting.length : at
185 }
186 const out = rows.map(row => {
187 let lane = waiting.indexOf(row.sha)
188 const isFromAbove = lane >= 0
189 if (!isFromAbove) {
190 lane = free()
191 colours[lane] = nextColour++
192 }
193 const colour = colours[lane] ?? 0
194 const joins: GraphRow['joins'] = []
195 waiting.forEach((sha, at) => {
196 if (at !== lane && sha === row.sha) {
197 joins.push({ lane: at, colour: colours[at] ?? 0 })
198 waiting[at] = null
199 }
200 })
201 const through = waiting.flatMap((sha, at) => (at !== lane && sha !== null ? [{ lane: at, colour: colours[at] ?? 0 }] : []))
202 const [first, ...others] = row.parents
203 waiting[lane] = first ?? null
204 const forks: GraphRow['forks'] = []
205 for (const parent of others) {
206 const held = waiting.indexOf(parent)
207 if (held >= 0) {
208 forks.push({ lane: held, colour: colours[held] ?? 0 })
209 continue
210 }
211 const at = free()
212 waiting[at] = parent
213 colours[at] = nextColour++
214 forks.push({ lane: at, colour: colours[at] ?? 0 })
215 }
216 while (waiting.length > 0 && waiting[waiting.length - 1] === null) waiting.pop()
217 widest = Math.max(widest, lane + 1, ...through.map(one => one.lane + 1), ...forks.map(one => one.lane + 1), ...joins.map(one => one.lane + 1))
218
219 return { lane, colour, isFromAbove, isToBelow: first !== undefined, through, joins, forks }
220 })
221
222 return { rows: out, lanes: widest }
223}
224
225export const commitArgs = (sha: string): string[] => [
226 'show',
227 '--no-patch',
228 '--color=never',
229 '--date=relative',
230 `--format=%H${SEP}%an${SEP}%ad${SEP}%s${SEP}%b`,
231 sha,
232]
233
234export function parseCommit(out: string): { sha: string; author: string; when: string; subject: string; body: string } | undefined {
235 const [sha = '', author = '', when = '', subject = '', ...body] = out.split(SEP)
236
237 return /^[0-9a-f]{7,40}$/.test(sha.trim())
238 ? { sha: sha.trim(), author, when, subject, body: body.join(SEP).trim() }
239 : undefined
240}
241
242/** `git show --name-status --format=`: renames and copies named by where they went. */
243export function parseNameStatus(out: string): CommitFile[] {
244 return out
245 .replace(/\r\n/g, '\n')
246 .split('\n')
247 .map(line => line.split('\t'))
248 .filter(cols => cols.length >= 2 && cols[0] !== '')
249 .map(cols => ({ status: (cols[0] ?? 'M').slice(0, 1), path: cols[cols.length - 1] ?? '' }))
250}
251
252/** Only shas git itself printed go back to git. */
253export const isSha = (text: string): boolean => /^[0-9a-f]{4,40}$/.test(text)
254
255/** The last few lines of a command's complaint: enough to say what went wrong in a toast. */
256export function tail(text: string, lines = 3): string {
257 return text
258 .replace(/\r\n/g, '\n')
259 .split('\n')
260 .map(line => line.trim())
261 .filter(line => line !== '' && !line.startsWith('hint:'))
262 .slice(-lines)
263 .join(' ')
264}
265
266// ── gh ──────────────────────────────────────────────────────────────────────
267
268const obj = (value: unknown): Record<string, unknown> =>
269 typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
270const str = (value: unknown): string => (typeof value === 'string' ? value : '')
271const int = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
272const parse = (text: string): unknown => {
273 try {
274 return JSON.parse(text) as unknown
275 } catch {
276 return undefined
277 }
278}
279
280export const PULL_FIELDS = 'number,title,url,state,isDraft,author,headRefName,statusCheckRollup'
281
282/** One verdict over a pull request's checks. */
283export function checksOf(rollup: unknown): Checks {
284 if (!Array.isArray(rollup) || rollup.length === 0) return ''
285 const states = rollup.map(one => {
286 const check = obj(one)
287
288 return (str(check.conclusion) || str(check.state) || str(check.status)).toUpperCase()
289 })
290 if (states.some(state => ['FAILURE', 'CANCELLED', 'TIMED_OUT', 'ERROR', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes(state))) return 'fail'
291 if (states.some(state => ['', 'PENDING', 'EXPECTED', 'QUEUED', 'IN_PROGRESS', 'WAITING'].includes(state))) return 'pending'
292
293 return 'pass'
294}
295
296function toPull(value: unknown): Pull | null {
297 const pull = obj(value)
298 const number = int(pull.number)
299
300 return number === 0
301 ? null
302 : {
303 number,
304 title: str(pull.title),
305 url: str(pull.url),
306 state: str(pull.state),
307 isDraft: pull.isDraft === true,
308 author: str(obj(pull.author).login),
309 branch: str(pull.headRefName),
310 checks: checksOf(pull.statusCheckRollup),
311 }
312}
313
314export const parsePull = (text: string): Pull | null => toPull(parse(text))
315
316export function parsePulls(text: string): Pull[] {
317 const list = parse(text)
318
319 return Array.isArray(list) ? list.map(toPull).filter((one): one is Pull => one !== null) : []
320}
321
322export const RUN_FIELDS = 'databaseId,name,displayTitle,status,conclusion,event,createdAt,url'
323
324export function parseRuns(text: string): Run[] {
325 const list = parse(text)
326
327 return Array.isArray(list)
328 ? list
329 .map(one => {
330 const run = obj(one)
331
332 return {
333 id: int(run.databaseId),
334 name: str(run.name),
335 title: str(run.displayTitle),
336 status: str(run.status),
337 conclusion: str(run.conclusion),
338 event: str(run.event),
339 createdAt: str(run.createdAt),
340 url: str(run.url),
341 }
342 })
343 .filter(run => run.id !== 0)
344 : []
345}
346
347export function parseJobs(text: string): Job[] {
348 const jobs = obj(parse(text)).jobs
349
350 return Array.isArray(jobs)
351 ? jobs.map(one => {
352 const job = obj(one)
353 const steps = Array.isArray(job.steps) ? job.steps.map(obj) : []
354
355 return {
356 name: str(job.name),
357 status: str(job.status),
358 conclusion: str(job.conclusion),
359 url: str(job.url),
360 failed: steps.filter(step => str(step.conclusion) === 'failure').map(step => str(step.name)),
361 }
362 })
363 : []
364}
365
366/** A failed run, as a request to Claude: what failed, and how to read its logs. */
367export function runPrompt(run: Run, branch: string, jobs: readonly Job[]): string {
368 const failed = jobs.filter(job => job.conclusion === 'failure')
369 const list = failed
370 .map(job => `- ${job.name}${job.failed.length > 0 ? ` (failed at: ${job.failed.join(', ')})` : ''}`)
371 .join('\n')
372
373 return [
374 `The GitHub Actions run "${run.title || run.name}" (workflow ${run.name}) failed on branch ${branch}: ${run.url}`,
375 failed.length > 0 ? `Failed jobs:\n${list}` : '',
376 `Read its logs with \`gh run view ${run.id} --log-failed\`, find the cause, and fix it.`,
377 ]
378 .filter(part => part !== '')
379 .join('\n\n')
380}
381
382/** A request to Claude for a commit message over what is staged. */
383export const COMMIT_PROMPT =
384 'Read the staged changes (`git diff --cached`) and write a short, specific commit message for them. Show me the message before committing.'
385hooks/paths.ts 78 lines1// Paths as the pane keeps them: forward slashes, no trailing slash, whatever
2// the platform printed. Windows hands out `C:\Users\me`, git `C:/Users/me`;
3// both are `C:/Users/me` here, and a drive letter compares without case.
4
5const DRIVE = /^[A-Za-z]:\//
6
7/** One spelling of a path: `/` separators, no doubled or trailing slash (roots keep theirs). */
8export function norm(path: string): string {
9 if (path === '') return ''
10 const unc = path.startsWith('\\\\') || path.startsWith('//')
11 let out = path.replace(/\\/g, '/').replace(/\/{2,}/g, '/')
12 if (unc) out = `/${out}`
13 if (/^[A-Za-z]:$/.test(out)) out = `${out}/`
14 if (out.length > 1 && out.endsWith('/') && !/^[A-Za-z]:\/$/.test(out)) out = out.slice(0, -1)
15
16 return DRIVE.test(out) ? `${out[0]!.toUpperCase()}${out.slice(1)}` : out
17}
18
19const fold = (path: string): string => (DRIVE.test(path) || path.startsWith('//') ? path.toLowerCase() : path)
20
21export function same(a: string, b: string): boolean {
22 return fold(norm(a)) === fold(norm(b))
23}
24
25/** Whether `path` is `root` or lies inside it. */
26export function isUnder(path: string, root: string): boolean {
27 const p = fold(norm(path))
28 const r = fold(norm(root))
29 if (p === '' || r === '') return false
30 if (p === r) return true
31
32 return p.startsWith(r.endsWith('/') ? r : `${r}/`)
33}
34
35export function join(root: string, ...parts: string[]): string {
36 return norm([root, ...parts.filter(part => part !== '')].join('/'))
37}
38
39export function base(path: string): string {
40 const n = norm(path)
41 const cut = n.lastIndexOf('/')
42
43 return cut < 0 || cut === n.length - 1 ? n : n.slice(cut + 1)
44}
45
46export function dir(path: string): string {
47 const n = norm(path)
48 const cut = n.lastIndexOf('/')
49 if (cut < 0) return ''
50 if (cut === 0) return '/'
51
52 return norm(n.slice(0, cut + (/^[A-Za-z]:$/.test(n.slice(0, cut)) ? 1 : 0)))
53}
54
55/** `path` relative to `root`, or `path` itself when it is not inside it. */
56export function rel(path: string, root: string): string {
57 const p = norm(path)
58 const r = norm(root)
59 if (!isUnder(p, r)) return p
60 if (same(p, r)) return ''
61
62 return p.slice(r.endsWith('/') ? r.length : r.length + 1)
63}
64
65/** A path as the person's own shell would print it. */
66export function native(path: string, isWindows: boolean): string {
67 return isWindows ? norm(path).replace(/\//g, '\\') : norm(path)
68}
69
70/** `~` for the home folder, and the head of a long path cut away. */
71export function short(path: string, home: string, max: number): string {
72 let shown = norm(path)
73 if (home !== '' && isUnder(shown, home)) shown = `~${shown.slice(norm(home).length)}`
74 if (shown.length <= max) return shown
75
76 return `…${shown.slice(shown.length - max + 1)}`
77}
78hooks/ports.ts 361 lines1import type { PortKind, PortRow, Probe } from '../types'
2
3// What is listening on this machine, read from the platform's own tools and
4// sorted into what a developer started and what the system did.
5
6export type Listener = { address: string; port: number; pid: number; name?: string }
7
8export type Proc = { pid: number; ppid: number; name: string; cmd: string; startedAt: number; memBytes: number }
9
10/** The address and port of `0.0.0.0:3000`, `[::1]:5173`, `*:8080`. */
11function splitEndpoint(endpoint: string): { address: string; port: number } | undefined {
12 const cut = endpoint.lastIndexOf(':')
13 if (cut <= 0) return undefined
14 const port = Number(endpoint.slice(cut + 1))
15 if (!Number.isInteger(port) || port <= 0 || port > 65535) return undefined
16 const address = endpoint.slice(0, cut).replace(/^\[|\]$/g, '')
17
18 return { address: address === '*' ? '0.0.0.0' : address, port }
19}
20
21/**
22 * `netstat -ano`: the TCP rows that listen. A listening socket has no peer,
23 * which netstat prints as `0.0.0.0:0` or `[::]:0`: that, not the state's
24 * name, is read, since the name is in the system's language.
25 */
26export function parseNetstat(text: string): Listener[] {
27 const found: Listener[] = []
28 for (const raw of text.split(/\r?\n/)) {
29 const cols = raw.trim().split(/\s+/)
30 if (cols[0]?.toUpperCase() !== 'TCP' || cols.length < 5) continue
31 const local = splitEndpoint(cols[1] ?? '')
32 const remote = cols[2] ?? ''
33 const pid = Number(cols[cols.length - 1])
34 if (local === undefined || !/:0$/.test(remote) || !Number.isInteger(pid)) continue
35 found.push({ ...local, pid })
36 }
37
38 return found
39}
40
41/** `lsof -nP -iTCP -sTCP:LISTEN -F pcn`: a `p` line per process, then its `c` name and `n` endpoints. */
42export function parseLsof(text: string): Listener[] {
43 const found: Listener[] = []
44 let pid = 0
45 let name = ''
46 for (const line of text.split(/\r?\n/)) {
47 const tag = line[0]
48 const value = line.slice(1)
49 if (tag === 'p') pid = Number(value)
50 else if (tag === 'c') name = value
51 else if (tag === 'n') {
52 const local = splitEndpoint(value.split('->')[0] ?? '')
53 if (local !== undefined && pid > 0) found.push({ ...local, pid, name })
54 }
55 }
56
57 return found
58}
59
60const field = (row: unknown, at: number): unknown => (Array.isArray(row) ? row[at] : undefined)
61const num = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
62const str = (value: unknown): string => (typeof value === 'string' ? value : '')
63
64/** The hidden host's process table (`processScript`): `[pid, ppid, name, cmd, startedAt, memBytes]` rows. */
65export function parseProcessJson(text: string): Map<number, Proc> {
66 const procs = new Map<number, Proc>()
67 let rows: unknown
68 try {
69 rows = JSON.parse(text)
70 } catch {
71 return procs
72 }
73 // One row alone comes back unwrapped.
74 const list = Array.isArray(rows) && rows.length > 0 && !Array.isArray(rows[0]) ? [rows] : rows
75 if (!Array.isArray(list)) return procs
76 for (const row of list) {
77 const pid = num(field(row, 0))
78 procs.set(pid, {
79 pid,
80 ppid: num(field(row, 1)),
81 name: str(field(row, 2)),
82 cmd: str(field(row, 3)),
83 startedAt: num(field(row, 4)),
84 memBytes: num(field(row, 5)),
85 })
86 }
87
88 return procs
89}
90
91/** `tasklist /fo csv /nh`: names and memory only, for when no PowerShell can be asked. */
92export function parseTasklist(text: string): Map<number, Proc> {
93 const procs = new Map<number, Proc>()
94 for (const line of text.split(/\r?\n/)) {
95 const cells = [...line.matchAll(/"([^"]*)"/g)].map(match => match[1] ?? '')
96 const pid = Number(cells[1])
97 if (cells.length < 5 || !Number.isInteger(pid)) continue
98 const kb = Number((cells[4] ?? '').replace(/[^\d]/g, ''))
99 procs.set(pid, { pid, ppid: 0, name: cells[0] ?? '', cmd: '', startedAt: 0, memBytes: kb * 1024 })
100 }
101
102 return procs
103}
104
105/** `ps -axo pid=,ppid=,rss=,command=` (rss in KB). */
106export function parsePs(text: string): Map<number, Proc> {
107 const procs = new Map<number, Proc>()
108 for (const line of text.split(/\r?\n/)) {
109 const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/.exec(line)
110 if (match === null) continue
111 const pid = Number(match[1])
112 const cmd = match[4] ?? ''
113 const program = (cmd.split(/\s+/)[0] ?? '').split('/').pop() ?? ''
114 procs.set(pid, { pid, ppid: Number(match[2]), name: program, cmd, startedAt: 0, memBytes: Number(match[3]) * 1024 })
115 }
116
117 return procs
118}
119
120/** A process name as people say it: no `.exe`, lower case. */
121export const bare = (name: string): string => name.replace(/\.exe$/i, '').toLowerCase()
122
123const DEV = new Set([
124 'node', 'nodemon', 'bun', 'deno', 'python', 'python3', 'pythonw', 'py', 'uvicorn', 'gunicorn', 'hypercorn',
125 'flask', 'java', 'javaw', 'dotnet', 'ruby', 'rails', 'puma', 'php', 'php-cgi', 'go', 'air', 'cargo',
126 'nginx', 'httpd', 'caddy', 'traefik', 'postgres', 'mysqld', 'mariadbd', 'redis-server', 'memurai',
127 'mongod', 'mongos', 'clickhouse', 'docker', 'dockerd', 'com.docker.backend', 'com.docker.proxy',
128 'wslrelay', 'vpnkit', 'hugo', 'jekyll', 'ngrok', 'cloudflared', 'esbuild', 'iisexpress', 'jupyter',
129 'streamlit', 'gradle', 'mvn', 'elixir', 'beam.smp', 'erl', 'dart', 'flutter', 'zola', 'livereload',
130])
131
132const SYSTEM = new Set([
133 'system', 'idle', 'system idle process', 'svchost', 'lsass', 'wininit', 'services', 'spoolsv', 'smss',
134 'csrss', 'winlogon', 'searchindexer', 'searchhost', 'msmpeng', 'nissrv', 'securityhealthservice',
135 'launchd', 'mdnsresponder', 'rapportd', 'controlce', 'controlcenter', 'sharingd', 'systemd',
136 'systemd-resolved', 'cupsd', 'avahi-daemon', 'sshd', 'remoted', 'identityservicesd',
137])
138
139// Command lines that say "a developer started this" whatever runs them.
140const DEV_HINTS = /node_modules|[\\/]\.venv[\\/]|manage\.py|\b(vite|next|nuxt|astro|webpack|serve|dev|runserver|--port)\b/i
141
142// Desktop apps that listen for their own helpers: never what a developer means by a server.
143const APPS = new Set(['claude', 'code', 'cursor', 'windsurf', 'electron', 'slack', 'discord', 'spotify', 'teams', 'msedgewebview2', 'chrome', 'msedge', 'firefox'])
144
145export function classify(name: string, cmd: string): PortKind {
146 const program = bare(name)
147 if (SYSTEM.has(program)) return 'system'
148 if (APPS.has(program)) return 'other'
149 if (DEV.has(program) || /^python\d/.test(program) || /^php\d/.test(program)) return 'dev'
150
151 return DEV_HINTS.test(cmd) ? 'dev' : 'other'
152}
153
154const FROM_COMMAND: readonly [RegExp, string][] = [
155 [/\bvite\b/i, 'Vite'],
156 [/\bnext(\.js)?[\\/ ]|[\\/]next[\\/]dist|\bnext (dev|start)\b/i, 'Next.js'],
157 [/\bnuxt/i, 'Nuxt'],
158 [/\bastro\b/i, 'Astro'],
159 [/\bremix\b/i, 'Remix'],
160 [/svelte-kit|sveltekit/i, 'SvelteKit'],
161 [/react-scripts/i, 'Create React App'],
162 [/@angular|\bng serve\b/i, 'Angular'],
163 [/storybook/i, 'Storybook'],
164 [/webpack/i, 'webpack'],
165 [/@nestjs|\bnest start\b/i, 'NestJS'],
166 [/manage\.py\s+runserver/i, 'Django'],
167 [/\buvicorn\b/i, 'Uvicorn'],
168 [/\bgunicorn\b/i, 'Gunicorn'],
169 [/\bflask\b/i, 'Flask'],
170 [/\bstreamlit\b/i, 'Streamlit'],
171 [/\bjupyter/i, 'Jupyter'],
172 [/http\.server/i, 'http.server'],
173 [/\brails\b/i, 'Rails'],
174 [/\bpuma\b/i, 'Puma'],
175 [/artisan\s+serve/i, 'Laravel'],
176 [/\bphp\b.*\s-S\s/i, 'PHP server'],
177 [/\bhugo\b/i, 'Hugo'],
178 [/\bjekyll\b/i, 'Jekyll'],
179 [/\bnodemon\b/i, 'nodemon'],
180]
181
182const FROM_PROGRAM: Record<string, string> = {
183 postgres: 'PostgreSQL',
184 mysqld: 'MySQL',
185 mariadbd: 'MariaDB',
186 'redis-server': 'Redis',
187 memurai: 'Redis',
188 mongod: 'MongoDB',
189 nginx: 'nginx',
190 httpd: 'Apache',
191 caddy: 'Caddy',
192 wslrelay: 'WSL',
193 'com.docker.backend': 'Docker',
194 dockerd: 'Docker',
195 iisexpress: 'IIS Express',
196}
197
198export type Answer = { server: string; poweredBy: string; body: string }
199
200/** What serves a port, from its command line and, where it answered HTTP, its headers and page. */
201export function frameworkOf(name: string, cmd: string, answer?: Answer): string {
202 if (answer !== undefined) {
203 if (answer.body.includes('/@vite/client')) return 'Vite'
204 if (answer.body.includes('__NEXT_DATA__') || answer.body.includes('/_next/') || /next\.js/i.test(answer.poweredBy)) return 'Next.js'
205 if (answer.body.includes('__nuxt')) return 'Nuxt'
206 }
207 for (const [pattern, label] of FROM_COMMAND) if (pattern.test(cmd)) return label
208 const program = FROM_PROGRAM[bare(name)]
209 if (program !== undefined) return program
210 if (answer !== undefined) {
211 const server = answer.server.toLowerCase()
212 if (/express/i.test(answer.poweredBy)) return 'Express'
213 if (server.includes('werkzeug')) return 'Flask'
214 if (server.includes('wsgiserver')) return 'Django'
215 if (server.includes('uvicorn')) return 'Uvicorn'
216 if (server.includes('simplehttp')) return 'http.server'
217 if (server.includes('kestrel')) return 'ASP.NET'
218 if (/php/i.test(answer.poweredBy)) return 'PHP'
219 }
220
221 return ''
222}
223
224const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }
225
226/** A page's `<title>`, decoded and cut to fit a row. */
227export function titleOf(html: string): string {
228 const match = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html)
229 if (match === null) return ''
230 const text = (match[1] ?? '')
231 .replace(/&#x([0-9a-f]+);/gi, (_, hex: string) => String.fromCodePoint(parseInt(hex, 16)))
232 .replace(/&#(\d+);/g, (_, dec: string) => String.fromCodePoint(Number(dec)))
233 .replace(/&(\w+);/g, (whole, name: string) => ENTITIES[name] ?? whole)
234 .replace(/\s+/g, ' ')
235 .trim()
236
237 return text.length > 80 ? `${text.slice(0, 79)}…` : text
238}
239
240const SECRET = /((?:^|\s|--?)[\w.-]*?(?:token|secret|passw(?:or)?d|pwd|api[-_]?key|access[-_]?key|private[-_]?key|auth|credentials?)\s*(?:=|:|\s+))("[^"]*"|'[^']*'|[^\s"']+)/gi
241const URL_PASSWORD = /(\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)[^\s@/]+@/gi
242
243/** A command line fit to show: values of anything named like a secret, and passwords in URLs, masked. */
244export function maskArgs(cmd: string): string {
245 const masked = cmd.replace(URL_PASSWORD, '$1***@').replace(SECRET, '$1***')
246
247 return masked.length > 400 ? `${masked.slice(0, 399)}…` : masked
248}
249
250const LOOPBACK = /^(127\.|::1$|localhost$)/
251
252export const isExposed = (addresses: readonly string[]): boolean =>
253 addresses.some(address => !LOOPBACK.test(address))
254
255/** The host a probe of the port should ask, by what it is bound to. */
256export function probeHost(addresses: readonly string[]): string {
257 if (addresses.some(a => a === '127.0.0.1' || a === '0.0.0.0')) return '127.0.0.1'
258 if (addresses.some(a => a === '::1' || a === '::')) return '[::1]'
259 const first = addresses[0] ?? '127.0.0.1'
260
261 return first.includes(':') ? `[${first}]` : first
262}
263
264/** Ports that speak something other than HTTP: asking them for a page only makes noise in their logs. */
265const NOT_HTTP = new Set([21, 22, 25, 53, 135, 139, 445, 1433, 1521, 3306, 3389, 5432, 5672, 6379, 9042, 11211, 27017, 27018, 5900])
266
267const DATABASES = new Set(['postgres', 'mysqld', 'mariadbd', 'redis-server', 'memurai', 'mongod', 'mongos'])
268
269export const isProbed = (row: Pick<PortRow, 'port' | 'kind' | 'name'>): boolean =>
270 row.kind === 'dev' && !NOT_HTTP.has(row.port) && !DATABASES.has(bare(row.name))
271
272/** Which workbench terminal's process tree a process belongs to; `''` for none. */
273export function termOf(pid: number, procs: ReadonlyMap<number, Proc>, hosts: ReadonlyMap<number, string>): string {
274 let at = pid
275 for (let depth = 0; depth < 16 && at > 4; depth += 1) {
276 const term = hosts.get(at)
277 if (term !== undefined) return term
278 const next = procs.get(at)?.ppid ?? 0
279 if (next === at) break
280 at = next
281 }
282
283 return ''
284}
285
286const HISTORY = 24
287
288/**
289 * The listeners as the view's rows: one per process and port, its IPv4 and
290 * IPv6 sockets together, what each process is, and what the last scan knew
291 * of it (its probes) carried over.
292 */
293export function toRows(
294 listeners: readonly Listener[],
295 procs: ReadonlyMap<number, Proc>,
296 hosts: ReadonlyMap<number, string>,
297 previous: readonly PortRow[],
298): PortRow[] {
299 const byKey = new Map<string, { port: number; pid: number; addresses: string[]; name: string }>()
300 for (const one of listeners) {
301 const key = `${one.pid}:${one.port}`
302 const held = byKey.get(key)
303 if (held === undefined) byKey.set(key, { port: one.port, pid: one.pid, addresses: [one.address], name: one.name ?? '' })
304 else if (!held.addresses.includes(one.address)) held.addresses.push(one.address)
305 }
306 const before = new Map(previous.map(row => [row.key, row]))
307 const rows: PortRow[] = []
308 for (const [key, one] of byKey) {
309 const proc = procs.get(one.pid)
310 const name = bare(proc?.name || one.name || (one.pid === 4 ? 'System' : `pid ${one.pid}`))
311 const cmd = proc?.cmd ?? ''
312 const termId = termOf(one.pid, procs, hosts)
313 const kind = termId !== '' ? 'dev' : classify(name, cmd)
314 const held = before.get(key)
315 rows.push({
316 key,
317 port: one.port,
318 addresses: one.addresses.sort(),
319 pid: one.pid,
320 name,
321 kind,
322 cmd: maskArgs(cmd),
323 startedAt: proc?.startedAt ?? 0,
324 memMb: Math.round((proc?.memBytes ?? 0) / 1048576),
325 termId,
326 framework: frameworkOf(name, cmd) || held?.framework || '',
327 probe: held?.probe ?? null,
328 history: held?.history ?? [],
329 isExposed: isExposed(one.addresses),
330 })
331 }
332 const rank = (row: PortRow): number => (row.termId !== '' ? 0 : row.kind === 'dev' ? 1 : row.kind === 'other' ? 2 : 3)
333
334 return rows.sort((a, b) => rank(a) - rank(b) || a.port - b.port || a.pid - b.pid)
335}
336
337/** A row with a probe's answer folded in: its latest, its history, and the framework it gave away. */
338export function withProbe(row: PortRow, probe: Probe | null, answer?: Answer): PortRow {
339 const history = probe === null ? row.history : [...row.history, probe.ms].slice(-HISTORY)
340 const framework = answer === undefined ? row.framework : frameworkOf(row.name, row.cmd, answer) || row.framework
341
342 return { ...row, probe, history, framework }
343}
344
345/** Which rows a filter shows. */
346export const shown = (rows: readonly PortRow[], filter: 'dev' | 'all'): PortRow[] =>
347 filter === 'all' ? [...rows] : rows.filter(row => row.kind === 'dev')
348
349/** How long a process has run, said briefly. */
350export function age(now: number, since: number): string {
351 if (since <= 0 || now < since) return ''
352 const s = Math.floor((now - since) / 1000)
353 if (s < 60) return `${s}s`
354 const m = Math.floor(s / 60)
355 if (m < 60) return `${m}m`
356 const h = Math.floor(m / 60)
357 if (h < 48) return `${h}h ${m % 60}m`
358
359 return `${Math.floor(h / 24)}d`
360}
361hooks/scripts.ts 132 lines1import type { Script } from '../types'
2
3// The tasks a project already declares, found from its own files: what the
4// person would otherwise type into a terminal by hand.
5
6export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'
7
8/** A folder as read for scripts: its file names, and the text of the few files that declare tasks. */
9export type Folder = { path: string; rel: string; names: readonly string[]; files: Readonly<Record<string, string>> }
10
11/** The files whose text `detect` reads, when a folder has them. */
12export const TASK_FILES = ['package.json', 'Makefile', 'makefile', 'justfile', 'Justfile', 'deno.json', 'deno.jsonc'] as const
13
14/** Which package manager a project uses: its `packageManager` field, else its lockfile. */
15export function packageManager(names: readonly string[], packageJson = ''): PackageManager {
16 const declared = /"packageManager"\s*:\s*"(npm|pnpm|yarn|bun)@/.exec(packageJson)?.[1]
17 if (declared !== undefined) return declared as PackageManager
18 if (names.includes('pnpm-lock.yaml')) return 'pnpm'
19 if (names.includes('bun.lockb') || names.includes('bun.lock')) return 'bun'
20 if (names.includes('yarn.lock')) return 'yarn'
21
22 return 'npm'
23}
24
25/** A task name as one shell word. */
26const word = (name: string): string => (/^[\w:.@/-]+$/.test(name) ? name : `'${name.replace(/'/g, "''")}'`)
27
28export function runCommand(pm: PackageManager, name: string): string {
29 if (pm === 'yarn') return `yarn ${word(name)}`
30
31 return `${pm} run ${word(name)}`
32}
33
34const FIRST = ['dev', 'start', 'serve', 'watch', 'build', 'test', 'lint', 'preview']
35
36const rank = (name: string): number => {
37 const at = FIRST.indexOf(name.split(':')[0] ?? name)
38
39 return at < 0 ? FIRST.length : at
40}
41
42/** `package.json`'s scripts, the everyday ones first. */
43export function fromPackageJson(text: string): string[] {
44 let scripts: unknown
45 try {
46 scripts = (JSON.parse(text) as { scripts?: unknown }).scripts
47 } catch {
48 return []
49 }
50 if (typeof scripts !== 'object' || scripts === null) return []
51 const names = Object.keys(scripts).filter(name => !/^(pre|post)/.test(name) || !(name.replace(/^(pre|post)/, '') in scripts))
52
53 return names.map((name, at) => ({ name, at })).sort((a, b) => rank(a.name) - rank(b.name) || a.at - b.at).map(one => one.name)
54}
55
56/** A Makefile's targets: plain names, not special (`.PHONY`), patterns (`%.o`) or variables. */
57export function fromMakefile(text: string): string[] {
58 const found: string[] = []
59 for (const line of text.split(/\r?\n/)) {
60 const match = /^([A-Za-z0-9][A-Za-z0-9_.-]*)\s*:(?![:=])/.exec(line)
61 if (match?.[1] !== undefined && !found.includes(match[1])) found.push(match[1])
62 }
63
64 return found
65}
66
67/** A justfile's recipes: unindented `name args:` lines, private (`_name`) ones left out. */
68export function fromJustfile(text: string): string[] {
69 const found: string[] = []
70 for (const line of text.split(/\r?\n/)) {
71 const match = /^@?([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s+[^:]*?)?:(?![:=])/.exec(line)
72 if (match?.[1] !== undefined && !found.includes(match[1])) found.push(match[1])
73 }
74
75 return found
76}
77
78/** `deno.json`'s tasks (comments in a `.jsonc` are dropped first). */
79export function fromDenoJson(text: string): string[] {
80 try {
81 const tasks = (JSON.parse(text.replace(/^\s*\/\/.*$/gm, '')) as { tasks?: unknown }).tasks
82
83 return typeof tasks === 'object' && tasks !== null ? Object.keys(tasks) : []
84 } catch {
85 return []
86 }
87}
88
89const MAX_PER_KIND = 12
90
91/** Every task a folder declares, as one-click scripts. */
92export function detect(folder: Folder): Script[] {
93 const { names, files } = folder
94 const found: Script[] = []
95 const add = (kind: string, label: string, cmd: string): void => {
96 found.push({ id: `${folder.rel}|${kind}|${label}`, label, cmd, cwd: folder.path, kind, folder: folder.rel })
97 }
98 const pkg = files['package.json']
99 if (pkg !== undefined) {
100 const pm = packageManager(names, pkg)
101 for (const name of fromPackageJson(pkg).slice(0, MAX_PER_KIND)) add(pm, name, runCommand(pm, name))
102 }
103 const deno = files['deno.json'] ?? files['deno.jsonc']
104 if (deno !== undefined) for (const name of fromDenoJson(deno).slice(0, MAX_PER_KIND)) add('deno', name, `deno task ${word(name)}`)
105 const make = files.Makefile ?? files.makefile
106 if (make !== undefined) for (const name of fromMakefile(make).slice(0, MAX_PER_KIND)) add('make', name, `make ${name}`)
107 const just = files.justfile ?? files.Justfile
108 if (just !== undefined) for (const name of fromJustfile(just).slice(0, MAX_PER_KIND)) add('just', name, `just ${name}`)
109 if (names.includes('Cargo.toml')) {
110 add('cargo', 'run', 'cargo run')
111 add('cargo', 'test', 'cargo test')
112 add('cargo', 'build', 'cargo build')
113 }
114 if (names.includes('go.mod')) {
115 add('go', 'run', 'go run .')
116 add('go', 'test', 'go test ./...')
117 }
118 if (names.some(name => /^(docker-)?compose\.ya?ml$/.test(name))) add('compose', 'up', 'docker compose up')
119 if (names.some(name => /\.(csproj|fsproj|sln)$/.test(name))) {
120 add('dotnet', 'run', 'dotnet run')
121 add('dotnet', 'watch', 'dotnet watch')
122 add('dotnet', 'test', 'dotnet test')
123 }
124 if (names.includes('manage.py')) add('django', 'runserver', 'python manage.py runserver')
125
126 return found
127}
128
129/** Folders under a project worth looking in for scripts of their own (a monorepo's packages). */
130export const isProjectDir = (name: string): boolean =>
131 !name.startsWith('.') && !['node_modules', 'dist', 'build', 'target', 'vendor', 'out', 'coverage', 'bin', 'obj'].includes(name)
132hooks/mood.ts 27 lines1import type { Mood } from '../types'
2
3// What the mascot shows for a tool Claude calls. Tools a plugin or MCP server
4// adds go by their name's shape; anything else is plain work.
5
6const READING = new Set(['Read', 'Grep', 'Glob', 'LS', 'NotebookRead', 'ListMcpResourcesTool', 'ReadMcpResourceTool', 'ReadMcpResourceDirTool'])
7const RUNNING = new Set(['Bash', 'PowerShell', 'BashOutput', 'KillShell', 'Monitor', 'TaskStop'])
8const ON_THE_WEB = new Set(['WebSearch', 'WebFetch'])
9const HELPERS = new Set(['Agent', 'Task'])
10const ASKING = new Set(['AskUserQuestion', 'ExitPlanMode'])
11const PLANNING = new Set(['TodoWrite', 'ToolSearch', 'Skill', 'EnterPlanMode'])
12
13export function moodOfTool(tool: string): Mood {
14 if (HELPERS.has(tool)) return 'helpers'
15 if (ASKING.has(tool)) return 'wait'
16 if (READING.has(tool)) return 'read'
17 if (RUNNING.has(tool) || tool.startsWith('mcp__terminal__')) return 'run'
18 if (ON_THE_WEB.has(tool) || /^mcp__(Claude_Browser|claude-in-chrome)__/.test(tool)) return 'web'
19 if (PLANNING.has(tool)) return 'think'
20
21 return 'work'
22}
23
24/** How long a passing mood stays before the otter goes back to sleep. */
25export const DONE_MS = 4000
26export const OOPS_MS = 5000
27hooks/shell-host.ts 374 lines1// The PowerShell that stands behind each terminal tab.
2//
3// The engine streams a child's output but writes its standard input once and
4// closes it, so keystrokes cannot be piped into a shell. Instead the shell
5// listens: this script opens one runspace (so variables, functions, modules
6// and the current folder last from command to command, as in a PowerShell
7// window), starts a small HTTP server on 127.0.0.1 at a free port, and takes
8// commands over it. What a command prints goes to this process's stdout and
9// stderr, which the hooks module reads; marks on stdout (`\x1e[[wb:...]]`)
10// say the server is up, that a command started, and how it ended.
11//
12// Every request carries the token the hooks module put in WB_TOKEN; the
13// variable is cleared at once so no command (or anything it starts) sees it.
14//
15// A pipe is no terminal, and Node programs that see none leave out what they
16// draw for one: Expo its QR code and key menu, chalk its colours. The host
17// writes a small preload to the temp folder and names it in NODE_OPTIONS, so
18// every Node program run here is told its output is a terminal as wide as the
19// pane's screen, and FORCE_COLOR asks for colour of every program that reads it.
20// Written to run under Windows PowerShell 5.1 as well as PowerShell 7.
21
22export const HOST_SCRIPT = String.raw`
23$ErrorActionPreference = 'Continue'
24$utf8 = New-Object System.Text.UTF8Encoding $false
25try { [Console]::OutputEncoding = $utf8 } catch {}
26$OutputEncoding = $utf8
27$token = $env:WB_TOKEN
28$env:WB_TOKEN = $null
29$isWin = [Environment]::OSVersion.Platform -eq 'Win32NT'
30$crlf = [string][char]13 + [char]10
31$mark = [string][char]30 + '[[wb:'
32function Out-Mark([string]$m) { [Console]::Out.WriteLine($mark + $m + ']]'); [Console]::Out.Flush() }
33function To-B64([string]$s) { [Convert]::ToBase64String($utf8.GetBytes($s)) }
34
35$tty = @'
36const readline = require('readline')
37const columns = Number(process.env.WB_COLUMNS) || 120
38for (const s of [process.stdout, process.stderr]) {
39 if (s.isTTY) continue
40 s.isTTY = true; s.columns = columns; s.rows = 40
41 s.getColorDepth = () => 8; s.hasColors = () => true; s.getWindowSize = () => [s.columns, s.rows]
42 s.cursorTo = (x, y, cb) => readline.cursorTo(s, x, y, cb)
43 s.moveCursor = (dx, dy, cb) => readline.moveCursor(s, dx, dy, cb)
44 s.clearLine = (d, cb) => readline.clearLine(s, d, cb)
45 s.clearScreenDown = cb => readline.clearScreenDown(s, cb)
46}
47if (!process.stdin.isTTY) { process.stdin.isTTY = true; process.stdin.isRaw = false; process.stdin.setRawMode = m => { process.stdin.isRaw = m; return process.stdin } }
48'@
49try {
50 $ttyFile = Join-Path ([IO.Path]::GetTempPath()) 'workbench-tty.cjs'
51 [IO.File]::WriteAllText($ttyFile, $tty)
52 $req = '--require "' + ($ttyFile -replace '\\', '/') + '"'
53 if (-not ([string]$env:NODE_OPTIONS).Contains($req)) { $env:NODE_OPTIONS = ($req + ' ' + $env:NODE_OPTIONS).Trim() }
54 $env:FORCE_COLOR = '1'
55} catch {}
56
57$rs = [runspacefactory]::CreateRunspace($Host)
58$rs.Open()
59
60function Invoke-In([string]$code, [object[]]$params) {
61 $p = [powershell]::Create(); $p.Runspace = $rs
62 [void]$p.AddScript($code)
63 foreach ($a in $params) { [void]$p.AddArgument($a) }
64 try {
65 $r = $p.Invoke()
66 foreach ($e in $p.Streams.Error) { Out-Mark ('info:' + (To-B64 ([string]$e))) }
67 return $r
68 } catch {
69 Out-Mark ('info:' + (To-B64 $_.Exception.Message))
70 } finally { $p.Dispose() }
71}
72
73$start = $env:WB_CWD
74if (-not $start) { $start = [Environment]::CurrentDirectory }
75$prof = ''
76if ($env:WB_PROFILE -eq '1') { try { $prof = [string]$PROFILE.CurrentUserCurrentHost } catch {} }
77[void](Invoke-In @'
78$ProgressPreference = 'SilentlyContinue'
79try { $PSStyle.OutputRendering = 'PlainText' } catch {}
80try { Set-Location -LiteralPath $args[0] } catch {}
81if ($args[1] -and (Test-Path -LiteralPath $args[1])) { . $args[1] | Out-Null }
82'@ @($start, $prof))
83
84$WRAP = @'
85$global:LASTEXITCODE = 0
86$global:__wbFailed = $false
87# An error as PowerShell prints one for a command typed at its prompt. Where the record
88# points into this wrapper (Write-Error, a throw), the line it shows would be ours, not
89# the person's: those print as one line, "Name: message", as pwsh's concise view does.
90function global:__wbError($e) {
91 $inv = $e.InvocationInfo
92 if ($inv -ne $null -and [string]$inv.Line -like '*__wbCmd*') {
93 $name = ''
94 if ($inv.MyCommand -ne $null) { $name = [string]$inv.MyCommand.Name }
95 if (-not $name) { $name = [string]$e.CategoryInfo.Reason }
96 if ($name -eq 'WriteErrorException') { $name = 'Write-Error' }
97 $msg = [string]$e.Exception.Message
98 if ($e.ErrorDetails -ne $null -and $e.ErrorDetails.Message) { $msg = [string]$e.ErrorDetails.Message }
99 if ($name) { return $name + ': ' + $msg }
100 return $msg
101 }
102 return (($e | Out-String -Width $__wbWidth).TrimEnd())
103}
104# Run as typed, so Get-History, h and r know it, as at a PowerShell prompt.
105try { Add-History -InputObject ([pscustomobject]@{ CommandLine = $__wbCmd; ExecutionStatus = 'Completed'; StartExecutionTime = [datetime]::Now; EndExecutionTime = [datetime]::Now }) } catch {}
106# A line that does not parse is shown as PowerShell shows it, pointing at the fault.
107$__wbParse = $null
108[void][System.Management.Automation.Language.Parser]::ParseInput($__wbCmd, [ref]$null, [ref]$__wbParse)
109if ($__wbParse -and $__wbParse.Count -gt 0) {
110 $global:__wbFailed = $true
111 $f = $__wbParse[0]
112 $row = [Math]::Max(1, $f.Extent.StartLineNumber)
113 $src = [regex]::Split($__wbCmd, '\r?\n')
114 $text = ''
115 if ($row -le $src.Length) { $text = $src[$row - 1] }
116 $mark = (' ' * [Math]::Max(0, $f.Extent.StartColumnNumber - 1)) + ('~' * [Math]::Max(1, [Math]::Min(80, $f.Extent.Text.Length)))
117 [Console]::Error.WriteLine('ParserError: ' + [Environment]::NewLine + 'Line |' + [Environment]::NewLine + ('{0,4} | {1}' -f $row, $text) + [Environment]::NewLine + ' | ' + $mark + [Environment]::NewLine + ' | ' + $f.Message)
118 [Console]::Error.Flush()
119 return
120}
121try {
122 . ([scriptblock]::Create($__wbCmd)) *>&1 | ForEach-Object {
123 if ($_ -is [System.Management.Automation.ErrorRecord]) {
124 if ([string]$_.FullyQualifiedErrorId -like 'NativeCommandError*') { [string]$_.Exception.Message }
125 else {
126 $global:__wbFailed = $true
127 [Console]::Error.WriteLine((__wbError $_))
128 [Console]::Error.Flush()
129 }
130 }
131 elseif ($_ -is [System.Management.Automation.WarningRecord]) { 'WARNING: ' + $_.Message }
132 elseif ($_ -is [System.Management.Automation.VerboseRecord]) { 'VERBOSE: ' + $_.Message }
133 elseif ($_ -is [System.Management.Automation.DebugRecord]) { 'DEBUG: ' + $_.Message }
134 elseif ($_ -is [System.Management.Automation.InformationRecord]) { [string]$_.MessageData }
135 else { $_ }
136 } | Out-String -Stream -Width $__wbWidth | ForEach-Object { [Console]::Out.WriteLine($_); [Console]::Out.Flush() }
137} catch {
138 $global:__wbFailed = $true
139 [Console]::Error.WriteLine((__wbError $_))
140 [Console]::Error.Flush()
141}
142'@
143
144$script:ps = $null
145$script:handle = $null
146$script:cmdId = ''
147$script:stopAt = $null
148$script:quit = $false
149$script:lastSeen = [DateTime]::UtcNow
150
151# Every process this shell started, and theirs, found from one snapshot before any is
152# ended: npx starts cmd starts node, and once the first is gone the rest no longer trace
153# back here. Ended deepest first, so nothing is left running without its parent.
154function Kill-Children {
155 try {
156 if ($isWin) {
157 $all = @(Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,Name -ErrorAction Stop)
158 $found = New-Object System.Collections.Generic.List[int]
159 $seen = New-Object System.Collections.Generic.HashSet[int]
160 [void]$seen.Add($PID)
161 $queue = New-Object System.Collections.Generic.Queue[int]
162 $queue.Enqueue($PID)
163 while ($queue.Count -gt 0) {
164 $parent = $queue.Dequeue()
165 foreach ($p in $all) {
166 $id = [int]$p.ProcessId
167 if ([int]$p.ParentProcessId -eq $parent -and $seen.Add($id)) {
168 if ($p.Name -ne 'conhost.exe') { $found.Add($id) }
169 $queue.Enqueue($id)
170 }
171 }
172 }
173 for ($i = $found.Count - 1; $i -ge 0; $i--) { try { Stop-Process -Id $found[$i] -Force -ErrorAction Stop } catch {} }
174 } else { & pkill -TERM -P $PID 2>&1 | Out-Null }
175 } catch {}
176}
177
178function Complete-Run {
179 $p = $script:ps
180 $state = 'ok'
181 try { [void]$p.EndInvoke($script:handle) } catch {}
182 if ([string]$p.InvocationStateInfo.State -eq 'Stopped') { $state = 'stopped' }
183 $exit = ''
184 $cwd = ''
185 try {
186 $le = $rs.SessionStateProxy.GetVariable('LASTEXITCODE')
187 if ($le -ne $null) { $exit = [string][int]$le }
188 if ($state -eq 'ok' -and (($rs.SessionStateProxy.GetVariable('__wbFailed') -eq $true) -or ($exit -ne '' -and $exit -ne '0'))) { $state = 'fail' }
189 $cwd = [string]$rs.SessionStateProxy.Path.CurrentLocation.Path
190 } catch {}
191 Out-Mark ('end:' + $script:cmdId + ':' + $state + ':' + $exit + ':' + (To-B64 $cwd))
192 $p.Dispose()
193 $script:ps = $null; $script:handle = $null; $script:cmdId = ''; $script:stopAt = $null
194}
195
196function Read-Request($ns) {
197 $buf = New-Object byte[] 8192
198 $ms = New-Object System.IO.MemoryStream
199 $headEnd = -1
200 $text = ''
201 while ($headEnd -lt 0) {
202 $n = $ns.Read($buf, 0, $buf.Length)
203 if ($n -le 0) { return $null }
204 $ms.Write($buf, 0, $n)
205 $text = [Text.Encoding]::ASCII.GetString($ms.GetBuffer(), 0, [int]$ms.Length)
206 $headEnd = $text.IndexOf($crlf + $crlf)
207 if ($headEnd -lt 0 -and $ms.Length -gt 65536) { return $null }
208 }
209 $lines = $text.Substring(0, $headEnd).Split([char]10)
210 $first = $lines[0].Trim().Split(' ')
211 $headers = @{}
212 for ($i = 1; $i -lt $lines.Length; $i++) {
213 $l = $lines[$i].TrimEnd([char]13)
214 $k = $l.IndexOf(':')
215 if ($k -gt 0) { $headers[$l.Substring(0, $k).Trim().ToLowerInvariant()] = $l.Substring($k + 1).Trim() }
216 }
217 $len = 0
218 if ($headers.ContainsKey('content-length')) { $len = [int]$headers['content-length'] }
219 $bodyAt = $headEnd + 4
220 while (($ms.Length - $bodyAt) -lt $len) {
221 $n = $ns.Read($buf, 0, $buf.Length)
222 if ($n -le 0) { break }
223 $ms.Write($buf, 0, $n)
224 }
225 $body = ''
226 if ($len -gt 0) { $body = $utf8.GetString($ms.GetBuffer(), $bodyAt, [Math]::Min($len, [int]$ms.Length - $bodyAt)) }
227 return @{ Path = [string]$first[1]; Token = [string]$headers['x-wb-token']; Body = $body }
228}
229
230function Send-Reply($ns, [int]$code, [string]$json) {
231 $b = $utf8.GetBytes($json)
232 $head = "HTTP/1.1 $code OK" + $crlf + 'Content-Type: application/json; charset=utf-8' + $crlf + 'Content-Length: ' + $b.Length + $crlf + 'Connection: close' + $crlf + $crlf
233 $h = [Text.Encoding]::ASCII.GetBytes($head)
234 $ns.Write($h, 0, $h.Length)
235 $ns.Write($b, 0, $b.Length)
236 $ns.Flush()
237}
238
239function Serve($client) {
240 $ns = $null
241 try {
242 $client.ReceiveTimeout = 3000
243 $client.SendTimeout = 3000
244 $ns = $client.GetStream()
245 $req = Read-Request $ns
246 if ($req -eq $null) { return }
247 if (-not $token -or $req.Token -ne $token) { Send-Reply $ns 401 '{"error":"token"}'; return }
248 $script:lastSeen = [DateTime]::UtcNow
249 $body = $null
250 if ($req.Body) { $body = $req.Body | ConvertFrom-Json }
251 $busy = $script:handle -ne $null
252 switch ($req.Path) {
253 '/ping' { Send-Reply $ns 200 ('{"ok":true,"busy":' + ([string]$busy).ToLowerInvariant() + '}'); return }
254 '/run' {
255 if ($busy) { Send-Reply $ns 409 '{"error":"busy"}'; return }
256 $id = [string]$body.id
257 if ($id -notmatch '^[A-Za-z0-9_-]{1,40}$') { Send-Reply $ns 400 '{"error":"id"}'; return }
258 $w = 120
259 if ($body.width) { $w = [Math]::Max(40, [Math]::Min(400, [int]$body.width)) }
260 $rs.SessionStateProxy.SetVariable('__wbCmd', [string]$body.cmd)
261 $rs.SessionStateProxy.SetVariable('__wbWidth', $w)
262 $env:WB_COLUMNS = [string]$w
263 $p = [powershell]::Create(); $p.Runspace = $rs
264 [void]$p.AddScript($WRAP)
265 $script:ps = $p; $script:cmdId = $id; $script:stopAt = $null
266 Out-Mark ('start:' + $id)
267 $script:handle = $p.BeginInvoke()
268 Send-Reply $ns 200 '{"ok":true}'
269 return
270 }
271 '/stop' {
272 # The programs first, while they still trace back here; then the pipeline.
273 if ($busy) { Kill-Children; [void]$script:ps.BeginStop($null, $null); $script:stopAt = [DateTime]::UtcNow }
274 Send-Reply $ns 200 '{"ok":true}'
275 return
276 }
277 '/complete' {
278 if ($busy) { Send-Reply $ns 200 '{"busy":true,"start":0,"length":0,"items":[]}'; return }
279 $p = [powershell]::Create(); $p.Runspace = $rs
280 try {
281 $r = [System.Management.Automation.CommandCompletion]::CompleteInput([string]$body.text, [int]$body.cursor, $null, $p)
282 $found = @($r.CompletionMatches | Select-Object -First 60 | ForEach-Object { $_.CompletionText })
283 Send-Reply $ns 200 (ConvertTo-Json -Compress -Depth 3 -InputObject @{ start = $r.ReplacementIndex; length = $r.ReplacementLength; items = $found })
284 } finally { $p.Dispose() }
285 return
286 }
287 '/eval' {
288 if ($busy) { Send-Reply $ns 409 '{"error":"busy"}'; return }
289 $p = [powershell]::Create(); $p.Runspace = $rs
290 try {
291 [void]$p.AddScript([string]$body.script)
292 $out = @($p.Invoke() | ForEach-Object { [string]$_ }) -join [string][char]10
293 if ($out -eq '') { $out = 'null' }
294 Send-Reply $ns 200 $out
295 } finally { $p.Dispose() }
296 return
297 }
298 '/exit' {
299 if ($busy) { try { $script:ps.Stop() } catch {} }
300 Send-Reply $ns 200 '{"ok":true}'
301 $script:quit = $true
302 return
303 }
304 default { Send-Reply $ns 404 '{"error":"route"}'; return }
305 }
306 } catch {
307 if ($ns -ne $null) { try { Send-Reply $ns 500 (ConvertTo-Json -Compress -InputObject @{ error = $_.Exception.Message }) } catch {} }
308 } finally { $client.Close() }
309}
310
311$listener = New-Object System.Net.Sockets.TcpListener ([System.Net.IPAddress]::Loopback), 0
312$listener.Start()
313Out-Mark ('ready:' + $listener.LocalEndpoint.Port + ':' + $PID)
314
315while (-not $script:quit) {
316 if ($script:handle -ne $null -and $script:handle.IsCompleted) { Complete-Run }
317 elseif ($script:stopAt -ne $null -and ([DateTime]::UtcNow - $script:stopAt).TotalSeconds -gt 2) {
318 Kill-Children
319 $script:stopAt = [DateTime]::UtcNow.AddSeconds(30)
320 }
321 if ($listener.Pending()) { Serve $listener.AcceptTcpClient() }
322 else { Start-Sleep -Milliseconds 30 }
323 # Nobody has asked anything for two minutes: the session that started this is gone.
324 if (([DateTime]::UtcNow - $script:lastSeen).TotalSeconds -gt 120) { $script:quit = $true }
325}
326
327try { if ($script:ps -ne $null) { $script:ps.Stop() } } catch {}
328Kill-Children
329try { $listener.Stop() } catch {}
330[Environment]::Exit(0)
331`
332
333/** A script as `-EncodedCommand` takes it: UTF-16LE, base64. */
334export function encodeCommand(script: string): string {
335 let bytes = ''
336 for (let at = 0; at < script.length; at += 1) {
337 const code = script.charCodeAt(at)
338 bytes += String.fromCharCode(code & 0xff, code >> 8)
339 }
340
341 return btoa(bytes)
342}
343
344/**
345 * What pwsh is given to run: a few words that take the host script from WB_HOST, clear
346 * the variable so no command sees it, and run it. The script itself rides in the
347 * environment, which holds 32,767 characters of it; encoded on the command line it
348 * would take nearly three times that, past what Windows lets a command line hold.
349 */
350export const HOST_BOOT = encodeCommand('$wbHost = $env:WB_HOST; $env:WB_HOST = $null; . ([scriptblock]::Create($wbHost))')
351
352/**
353 * For the hidden host that answers the Ports view: every process's id, parent
354 * and name, and for the listening ones their command line, start time and
355 * memory, as one compact JSON array of arrays.
356 */
357export function processScript(pids: readonly number[]): string {
358 const ids = pids.filter(pid => Number.isInteger(pid) && pid > 0).join(',')
359
360 return [
361 `$ids = @(${ids})`,
362 `$rows = @(Get-CimInstance Win32_Process -Property ProcessId,ParentProcessId,Name,CommandLine,CreationDate,WorkingSetSize | ForEach-Object {`,
363 ` $c = ''; $t = 0; $m = 0`,
364 ` if ($ids -contains [int]$_.ProcessId) {`,
365 ` $c = [string]$_.CommandLine`,
366 ` if ($_.CreationDate) { $t = [long]([DateTimeOffset]$_.CreationDate).ToUnixTimeMilliseconds() }`,
367 ` $m = [long]$_.WorkingSetSize`,
368 ` }`,
369 ` ,@([int]$_.ProcessId, [int]$_.ParentProcessId, [string]$_.Name, $c, $t, $m)`,
370 `})`,
371 `ConvertTo-Json -InputObject $rows -Compress -Depth 3`,
372 ].join('\n')
373}
374hooks/stream.ts 216 lines1// What a terminal's PowerShell host writes, read back: plain output lines,
2// and the host's own marks (`\x1e[[wb:...]]`) that say it is up, that a
3// command started, and how it ended. Output comes in pieces cut wherever the
4// child's writes were, so a line can span two pieces: the unfinished end of
5// each pipe is carried until its newline arrives.
6
7export const MARK = '\u001e[[wb:'
8
9export type Pipe = 'stdout' | 'stderr'
10
11export type StreamState = { stdout: string; stderr: string }
12
13export const FRESH: StreamState = { stdout: '', stderr: '' }
14
15export type Ended = 'ok' | 'fail' | 'stopped'
16
17/** A run of a line in one style, as the program's colour codes set it. */
18export type Styled = { text: string; color?: string; isBold?: boolean; isDim?: boolean; isUnderline?: boolean }
19
20export type StreamEvent =
21 | { kind: 'ready'; port: number; pid: number }
22 | { kind: 'start'; id: string }
23 | { kind: 'end'; id: string; state: Ended; exit: number | null; cwd: string }
24 | { kind: 'info'; text: string }
25 | { kind: 'line'; text: string; tone: 'out' | 'err'; runs?: Styled[] }
26
27/** Windows Terminal's "Campbell" colours for the 16 a program names: 30–37, then bright 90–97. */
28export const ANSI_COLORS = [
29 '#0C0C0C', '#C50F1F', '#13A10E', '#C19C00', '#0037DA', '#881798', '#3A96DD', '#CCCCCC',
30 '#767676', '#E74856', '#16C60C', '#F9F1A5', '#3B78FF', '#B4009E', '#61D6D6', '#F2F2F2',
31] as const
32
33const hex = (n: number): string => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0')
34
35/** One of the 256 colours `38;5;n` names. */
36function color256(n: number): string | undefined {
37 if (!Number.isInteger(n) || n < 0 || n > 255) return undefined
38 if (n < 16) return ANSI_COLORS[n]
39 if (n >= 232) return `#${hex(8 + (n - 232) * 10).repeat(3)}`
40 const at = n - 16
41 const level = (v: number): number => (v === 0 ? 0 : 55 + v * 40)
42
43 return `#${hex(level(Math.floor(at / 36)))}${hex(level(Math.floor(at / 6) % 6))}${hex(level(at % 6))}`
44}
45
46type Style = Omit<Styled, 'text'>
47
48/** A style after one SGR sequence's parameters (`1;33`, `38;5;214`, `0`). */
49function applySgr(style: Style, params: string): Style {
50 const codes = params === '' ? [0] : params.split(';').map(Number)
51 let next: Style = { ...style }
52 for (let at = 0; at < codes.length; at += 1) {
53 const code = codes[at]!
54 if (code === 0) next = {}
55 else if (code === 1) next.isBold = true
56 else if (code === 2) next.isDim = true
57 else if (code === 22) {
58 delete next.isBold
59 delete next.isDim
60 } else if (code === 4) next.isUnderline = true
61 else if (code === 24) delete next.isUnderline
62 else if (code >= 30 && code <= 37) next.color = ANSI_COLORS[code - 30]
63 else if (code >= 90 && code <= 97) next.color = ANSI_COLORS[code - 90 + 8]
64 else if (code === 39) delete next.color
65 else if (code === 38 && codes[at + 1] === 5) {
66 const picked = color256(codes[at + 2] ?? -1)
67 if (picked !== undefined) next.color = picked
68 at += 2
69 } else if (code === 38 && codes[at + 1] === 2) {
70 next.color = `#${hex(codes[at + 2] ?? 0)}${hex(codes[at + 3] ?? 0)}${hex(codes[at + 4] ?? 0)}`
71 at += 4
72 } else if (code === 48 && (codes[at + 1] === 5 || codes[at + 1] === 2)) at += codes[at + 1] === 5 ? 2 : 4
73 }
74
75 return next
76}
77
78const SGR = /\u001b\[([0-9;]*)m/g
79
80/**
81 * A line's styled runs as its colour codes paint it, after a carriage return keeps only
82 * the last rewrite; `undefined` for a line with no colour codes, read as plain text.
83 */
84export function ansiRuns(raw: string): Styled[] | undefined {
85 let line = raw.endsWith('\r') ? raw.slice(0, -1) : raw
86 const back = line.lastIndexOf('\r')
87 if (back >= 0) line = line.slice(back + 1)
88 if (!line.includes('\u001b[')) return undefined
89 const runs: Styled[] = []
90 let style: Style = {}
91 let from = 0
92 const push = (piece: string): void => {
93 const text = piece.replace(ANSI, '').replace(CONTROL, '')
94 if (text === '') return
95 const last = runs[runs.length - 1]
96 const same = last !== undefined && last.color === style.color && last.isBold === style.isBold && last.isDim === style.isDim && last.isUnderline === style.isUnderline
97 if (same) last.text += text
98 else runs.push({ text, ...style })
99 }
100 for (const match of line.matchAll(SGR)) {
101 push(line.slice(from, match.index))
102 style = applySgr(style, match[1] ?? '')
103 from = (match.index ?? 0) + match[0].length
104 }
105 push(line.slice(from))
106
107 return runs.some(run => run.color !== undefined || run.isBold === true || run.isDim === true || run.isUnderline === true) ? runs : undefined
108}
109
110// CSI sequences, OSC strings (ended by BEL or ST), and lone two-byte escapes.
111const ANSI = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[@-Z\\-_]/g
112// Every control character but the tab: a drawing refuses them.
113const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f]/g
114
115/**
116 * One line as a terminal would leave it on screen: colour codes gone, and of
117 * a line a progress bar rewrote with carriage returns, only its last state.
118 */
119export function clean(raw: string): string {
120 let line = raw.endsWith('\r') ? raw.slice(0, -1) : raw
121 const back = line.lastIndexOf('\r')
122 if (back >= 0) line = line.slice(back + 1)
123
124 return line.replace(ANSI, '').replace(CONTROL, '')
125}
126
127function decode64(text: string): string {
128 try {
129 const bytes = atob(text)
130 const array = new Uint8Array(bytes.length)
131 for (let at = 0; at < bytes.length; at += 1) array[at] = bytes.charCodeAt(at)
132
133 return new TextDecoder().decode(array)
134 } catch {
135 return ''
136 }
137}
138
139/** A mark's body (what follows `\x1e[[wb:`, up to `]]`), read; `undefined` for one this build does not know. */
140export function readMark(body: string): StreamEvent | undefined {
141 const parts = body.split(':')
142 const [kind] = parts
143 if (kind === 'ready') {
144 const port = Number(parts[1])
145 const pid = Number(parts[2])
146
147 return Number.isInteger(port) && port > 0 ? { kind, port, pid: Number.isInteger(pid) ? pid : 0 } : undefined
148 }
149 if (kind === 'start' && parts[1] !== undefined && parts[1] !== '') return { kind, id: parts[1] }
150 if (kind === 'end' && parts[1] !== undefined && parts[1] !== '') {
151 const said = parts[2]
152 const state: Ended = said === 'ok' || said === 'stopped' ? said : 'fail'
153 const exit = parts[3] === undefined || parts[3] === '' ? null : Number(parts[3])
154
155 return {
156 kind,
157 id: parts[1],
158 state,
159 exit: exit === null || Number.isFinite(exit) ? exit : null,
160 cwd: decode64(parts[4] ?? ''),
161 }
162 }
163 if (kind === 'info') return { kind, text: clean(decode64(parts[1] ?? '')) }
164
165 return undefined
166}
167
168/** One whole line of a pipe: an output line, a mark, or output followed by a mark. */
169function lineEvents(raw: string, pipe: Pipe): StreamEvent[] {
170 // Windows PowerShell 5.1 speaks CLIXML on a redirected stderr: its header and records are no output.
171 if (pipe === 'stderr' && /^(#< CLIXML|<Objs Version=)/.test(raw.trim())) return []
172 const events: StreamEvent[] = []
173 const at = pipe === 'stdout' ? raw.indexOf(MARK) : -1
174 if (at < 0) {
175 const runs = ansiRuns(raw)
176 const line: StreamEvent = { kind: 'line', text: clean(raw), tone: pipe === 'stdout' ? 'out' : 'err' }
177
178 return [runs === undefined ? line : { ...line, runs }]
179 }
180 // A command that wrote no newline at its end leaves its text before the mark.
181 const before = raw.slice(0, at)
182 if (clean(before) !== '') events.push({ kind: 'line', text: clean(before), tone: 'out' })
183 const close = raw.indexOf(']]', at)
184 const mark = close < 0 ? undefined : readMark(raw.slice(at + MARK.length, close))
185 if (mark !== undefined) events.push(mark)
186
187 return events
188}
189
190/** Takes one piece of a pipe; answers the state to carry and what the piece completed. */
191export function feed(state: StreamState, pipe: Pipe, text: string): { state: StreamState; events: StreamEvent[] } {
192 const held = state[pipe] + text
193 const cut = held.lastIndexOf('\n')
194 if (cut < 0) return { state: { ...state, [pipe]: held }, events: [] }
195 const events = held
196 .slice(0, cut)
197 .split('\n')
198 .flatMap(raw => lineEvents(raw, pipe))
199
200 return { state: { ...state, [pipe]: held.slice(cut + 1) }, events }
201}
202
203/** What each pipe has written of its current line so far: a live progress line, shown before its newline. */
204export function partial(state: StreamState): { text: string; tone: 'out' | 'err'; runs?: Styled[] }[] {
205 const shown: { text: string; tone: 'out' | 'err'; runs?: Styled[] }[] = []
206 const one = (raw: string, tone: 'out' | 'err'): { text: string; tone: 'out' | 'err'; runs?: Styled[] } => {
207 const runs = ansiRuns(raw)
208
209 return runs === undefined ? { text: clean(raw), tone } : { text: clean(raw), tone, runs }
210 }
211 if (state.stdout !== '' && !state.stdout.includes(MARK) && clean(state.stdout) !== '') shown.push(one(state.stdout, 'out'))
212 if (state.stderr !== '' && clean(state.stderr) !== '') shown.push(one(state.stderr, 'err'))
213
214 return shown
215}
216hooks/tab-name.ts 157 lines1// What a terminal tab is called after the command that keeps running in it, as
2// Windows Terminal names a tab after the program in it: `npm run dev` → `dev`,
3// `python app.py` → `app.py`, `docker compose up` → `compose up`.
4
5/** The longest a tab's name gets. */
6export const MAX_TAB_NAME = 18
7
8/** Plain words that, right after a tool, name what it does rather than what it works on. */
9const SUBCOMMANDS = new Set(['start', 'dev', 'run', 'serve', 'server', 'build', 'watch', 'up', 'preview', 'test', 'runserver'])
10
11const PACKAGE_MANAGERS = new Set(['npm', 'pnpm', 'yarn', 'bun'])
12/** What npm runs as a script without `run`. */
13const NPM_SCRIPTS = new Set(['start', 'test', 'stop', 'restart'])
14/** The package managers' own commands: anything else after pnpm, yarn or bun is a script's name. */
15const PM_BUILTINS = new Set([
16 'install', 'i', 'ci', 'add', 'remove', 'rm', 'uninstall', 'update', 'up', 'upgrade', 'outdated', 'audit', 'init', 'create',
17 'publish', 'pack', 'link', 'unlink', 'login', 'logout', 'config', 'cache', 'list', 'ls', 'info', 'view', 'why', 'version',
18 'help', 'store', 'prune', 'dedupe', 'rebuild', 'setup', 'env', 'global', 'workspace', 'workspaces',
19])
20/** Runners of a package's own command: npx vite, pnpm dlx, bunx. */
21const RUNNERS = new Set(['npx', 'bunx', 'pnpx'])
22/** Interpreters that run a file named after them. */
23const INTERPRETERS = new Set(['python', 'python3', 'py', 'node', 'deno', 'ruby', 'php', 'perl', 'tsx', 'ts-node'])
24/** npx options that take a value. */
25const RUNNER_VALUED = new Set(['-p', '--package', '-c', '--call'])
26
27/** A command's words, quotes kept together and removed. */
28function words(text: string): string[] {
29 return (text.match(/"[^"]*"?|'[^']*'?|\S+/g) ?? []).map(word => word.replace(/^["']|["']$/g, ''))
30}
31
32/** A program's name without its folder, extension or version: `C:\tools\ngrok.exe` → `ngrok`, `@scope/pkg@1` → `pkg`. */
33function bare(word: string): string {
34 const last = word.replace(/\\/g, '/').split('/').pop() ?? word
35 const noVersion = last.replace(/(.)@.*$/, '$1')
36
37 return noVersion.replace(/\.(exe|cmd|bat|ps1)$/i, '')
38}
39
40/** A file's name without its folder: `src/server.js` → `server.js`. */
41const fileName = (word: string): string => word.replace(/\\/g, '/').split('/').pop() ?? word
42
43const isFlag = (word: string): boolean => word.startsWith('-')
44
45/** A word that reads as a subcommand: a plain lowercase word, no path or extension. */
46const isPlainWord = (word: string | undefined): word is string => word !== undefined && /^[a-z][a-z0-9:-]*$/.test(word)
47
48/** `tool sub` when the next word is one of the known subcommands, else the tool. */
49function withSub(tool: string, next: string | undefined): string {
50 return isPlainWord(next) && SUBCOMMANDS.has(next) ? `${tool} ${next}` : tool
51}
52
53/** What `npx <tool> ...` is called: the tool, and its subcommand if it has one. */
54function runnerName(args: readonly string[]): string {
55 let at = 0
56 while (at < args.length && isFlag(args[at]!)) at += RUNNER_VALUED.has(args[at]!) ? 2 : 1
57 const tool = args[at]
58 if (tool === undefined) return ''
59
60 return withSub(bare(tool), args[at + 1])
61}
62
63/** The name for one command, without the chain around it. */
64function nameOne(segment: string): string {
65 const all = words(segment.trim().replace(/^[&.]\s+/, ''))
66 if (all.length === 0) return ''
67 const program = bare(all[0]!).toLowerCase()
68 const args = all.slice(1)
69 const plain = args.filter(word => !isFlag(word))
70
71 if (RUNNERS.has(program)) return runnerName(args)
72 if (PACKAGE_MANAGERS.has(program)) {
73 const [first, second] = plain
74 if (first === undefined) return program
75 if (first === 'run' || first === 'run-script') return second ?? program
76 if (first === 'dlx' || first === 'exec' || first === 'x') return runnerName(args.slice(args.indexOf(first) + 1))
77 if (program === 'npm') return NPM_SCRIPTS.has(first) ? first : `npm ${first}`
78 if (PM_BUILTINS.has(first)) return `${program} ${first}`
79
80 return first
81 }
82 if (INTERPRETERS.has(program)) {
83 // `python -m http.server` runs a module: the module is the name.
84 const module = args.indexOf('-m')
85 if (module >= 0 && args[module + 1] !== undefined) return args[module + 1]!
86 // deno task dev runs a task, as npm run does; deno run x.ts a file.
87 if (program === 'deno' && plain[0] === 'task') return plain[1] ?? 'deno task'
88 const rest = program === 'deno' && plain[0] === 'run' ? plain.slice(1) : plain
89 const file = rest[0]
90 if (file === undefined) return program
91 // `python manage.py runserver`, `php artisan serve`: the script's subcommand says what runs.
92 const next = rest[1]
93 if (isPlainWord(next) && !next.includes('.')) return next
94
95 return fileName(file)
96 }
97 if (program === 'docker' && plain[0] === 'compose') return withSub('compose', plain[1])
98 if (program === 'docker-compose') return withSub('compose', plain[0])
99
100 return withSub(program, plain[0])
101}
102
103/** `cd client`, `Set-Location ./client`, `pushd client`: the folder a chain moves into. */
104function cdTarget(segment: string): string | undefined {
105 const [verb, target] = words(segment.trim())
106 if (verb === undefined || target === undefined) return undefined
107
108 return /^(cd|chdir|sl|set-location|pushd|push-location)$/i.test(verb) ? target : undefined
109}
110
111export type TabName = {
112 /** What the command is called, cut to fit a tab; `''` when nothing could be read from it. */
113 name: string
114 /** The folder the command line moved into first (`cd client; npm run dev`), as typed. */
115 cdTo?: string
116}
117
118/**
119 * What a tab running `cmd` is called. Of a chain (`;`, `&&`, `||`) the last command names
120 * it, as the one left running; of a pipeline, its first stage, the one producing the output.
121 */
122export function tabName(cmd: string): TabName {
123 const segments = cmd.split(/;|&&|\|\|/).map(one => one.trim()).filter(one => one !== '')
124 let cdTo: string | undefined
125 for (const segment of segments.slice(0, -1)) cdTo = cdTarget(segment) ?? cdTo
126 const last = segments[segments.length - 1] ?? ''
127 const first = last.split('|')[0] ?? last
128 const name = nameOne(first)
129 const cut = name.length > MAX_TAB_NAME ? `${name.slice(0, MAX_TAB_NAME - 1)}…` : name
130
131 return cdTo === undefined ? { name: cut } : { name: cut, cdTo }
132}
133
134/** `name`, or `name 2`, `name 3`, ... so it differs from every one of `taken`. */
135export function unique(name: string, taken: readonly string[]): string {
136 if (!taken.includes(name)) return name
137 let n = 2
138 while (taken.includes(`${name} ${n}`)) n += 1
139
140 return `${name} ${n}`
141}
142
143/**
144 * A name the person gave the tab by typing it: `title frontend`, or PowerShell's own
145 * `$Host.UI.RawUI.WindowTitle = "frontend"`. `''` for `title` alone, which hands naming
146 * back to the commands; `undefined` when the line is not a rename.
147 */
148export function typedTitle(line: string): string | undefined {
149 const text = line.trim()
150 const titled = /^title(?:\s+(.*))?$/i.exec(text)
151 if (titled !== null) return (titled[1] ?? '').trim().replace(/^["']|["']$/g, '')
152 const host = /^\$host\.ui\.rawui\.windowtitle\s*=\s*(.+)$/i.exec(text)
153 if (host !== null) return host[1]!.trim().replace(/^["']|["']$/g, '')
154
155 return undefined
156}
157hooks/terminal-input.tsx 406 lines1import type { ClientModule } from 'claude-code'
2
3import { bufferOf, edit, keepSpaces, textOf, typed } from './edit-buffer'
4import type { Buffer } from './edit-buffer'
5
6// A terminal's input line: a surface module, so each key reaches it as it is
7// pressed. It keeps the line being typed and walks the history itself; what
8// needs the shell (running the line, stopping a command, completing a word)
9// it posts to the hooks module, which holds the PowerShell.
10
11export type Completion = { seq: number; start: number; length: number; items: string[] }
12
13export type InputProps = {
14 termId: string
15 /** What the session has printed, as coloured rows, drawn above the prompt. */
16 screen: { text: string; color: string; isItalic?: boolean; isBold?: boolean; isDim?: boolean; isUnderline?: boolean }[][]
17 /**
18 * Rows the module fills at the least, blank ones under the prompt included: its region
19 * is only as tall as what it draws, and a click there is what gives it the keys.
20 */
21 minRows: number
22 /** The prompt's text, `PS C:\repo>`. */
23 prompt: string
24 isBusy: boolean
25 isEnded: boolean
26 /** Commands run here before, the latest last. */
27 history: string[]
28 /** The shell's answer to the last Tab, matched to its request by `seq`. */
29 completion: Completion | null
30 /** Text the pane asks to put on the line (a command to edit and run again), matched by `seq`. */
31 fill: { seq: number; text: string } | null
32 /** The clipboard's text, read for a paste, to go in at the cursor; matched by `seq`. */
33 paste: { seq: number; text: string } | null
34 /** Whether the surface sets type proportionally: spaces must then be kept by hand. */
35 isProportional: boolean
36 /** The prompt's colour. */
37 accent: string
38 /** The typed text's colour. */
39 ink: string
40 bad: string
41 /** The colour of the quiet line under the prompt. */
42 hint: string
43}
44
45type Cycle = { items: string[]; index: number; start: number; length: number; base: string }
46
47type State = {
48 buffer: Buffer
49 /** Where in the history ↑ has walked to: `-1` is the line being typed. */
50 back: number
51 /** The line being typed, kept while ↑ shows older ones. */
52 draft: string
53 cycle: Cycle | null
54 askedSeq: number
55 fillSeq: number
56 pasteSeq: number
57 /** Whether the blinking cursor is in its off half. */
58 isBlinkOff: boolean
59 hasKeys: boolean
60 note: string
61 /** The whole line selected with ctrl+a: the next key acts on all of it. */
62 isAllSelected?: boolean
63}
64
65const EMPTY = bufferOf('')
66/** A terminal's cursor blink: half a second on, half off. */
67const BLINK_MS = 530
68
69/** The line with one completion put in for the word Tab was pressed on. */
70function complete(base: string, cycle: Pick<Cycle, 'start' | 'length'>, item: string): Buffer {
71 const text = base.slice(0, cycle.start) + item + base.slice(cycle.start + cycle.length)
72
73 return { lines: [text], row: 0, col: cycle.start + item.length }
74}
75
76/** Block characters a terminal draws pictures with: a QR code, a progress bar. */
77const BLOCKS = /^[ ▀▄█]+$/
78
79/** Whether a row is a picture drawn in block characters, as Expo's QR code is. */
80export function isBlockArt(row: readonly { text: string }[]): boolean {
81 const text = row.map(span => span.text).join('')
82
83 return /[▀▄█]/.test(text) && BLOCKS.test(text)
84}
85
86/**
87 * What would finish `line`: the rest of the latest command in `history` (latest last) that
88 * starts with it, ignoring case, as PSReadLine's history prediction; `''` for none.
89 */
90export function suggest(line: string, history: readonly string[]): string {
91 if (line.trim() === '') return ''
92 const typed = line.toLowerCase()
93 for (let at = history.length - 1; at >= 0; at -= 1) {
94 const one = history[at] ?? ''
95 if (one.length > line.length && one.toLowerCase().startsWith(typed)) return one.slice(line.length)
96 }
97
98 return ''
99}
100
101// What each instance last set, kept here as well: keys can arrive faster than frames,
102// and each must edit what the one before it left, not what was last drawn.
103const latest = new WeakMap<object, State>()
104// The instances whose blink is going: started once each.
105const blinking = new WeakSet<object>()
106
107const Input: ClientModule<InputProps, State> = (props, surface) => {
108 const put = (next: State): void => {
109 latest.set(surface, next)
110 surface.setState(next)
111 }
112 const { Box, Text } = surface.elements
113 const held = latest.get(surface) ?? surface.state
114 let state: State = held ?? {
115 buffer: EMPTY,
116 back: -1,
117 draft: '',
118 cycle: null,
119 askedSeq: 0,
120 fillSeq: props.fill?.seq ?? 0,
121 pasteSeq: props.paste?.seq ?? 0,
122 isBlinkOff: false,
123 hasKeys: false,
124 note: '',
125 }
126 // A fill from the pane replaces the line once.
127 if (props.fill !== null && props.fill.seq !== state.fillSeq) {
128 state = { ...state, buffer: bufferOf(props.fill.text, 0, props.fill.text.length), fillSeq: props.fill.seq, back: -1, cycle: null }
129 put(state)
130 }
131 // The clipboard's text, read for a paste, goes in at the cursor once.
132 if (props.paste !== null && props.paste.seq !== state.pasteSeq) {
133 const pasted = edit(state.buffer, { key: props.paste.text }, false) ?? state.buffer
134 state = { ...state, buffer: props.paste.text === '' ? state.buffer : pasted, pasteSeq: props.paste.seq, back: -1, cycle: null, isBlinkOff: false }
135 put(state)
136 }
137 // The shell answered a Tab: the first match goes in, and Tab again walks the rest.
138 const answer = props.completion
139 if (answer !== null && answer.seq === state.askedSeq && state.cycle === null && answer.items.length > 0) {
140 const base = textOf(state.buffer)
141 const cycle: Cycle = { items: answer.items, index: 0, start: answer.start, length: answer.length, base }
142 state = { ...state, cycle, buffer: complete(base, cycle, answer.items[0] ?? ''), askedSeq: -1 }
143 put(state)
144 } else if (answer !== null && answer.seq === state.askedSeq && answer.items.length === 0) {
145 state = { ...state, askedSeq: -1, note: 'no completions' }
146 put(state)
147 }
148 if (held === undefined) put(state)
149
150 // Keys can come faster than frames: each one edits what the last one left, not what was last drawn.
151 const now = (): State => latest.get(surface) ?? state
152 // Any key shows the cursor again, as a terminal does, before it goes on blinking.
153 // Any key but ctrl+a lets a selection go, as it does in a PowerShell window.
154 const set = (next: Partial<State>): void => put({ ...now(), hasKeys: true, note: '', isBlinkOff: false, isAllSelected: false, ...next })
155 if (!blinking.has(surface)) {
156 blinking.add(surface)
157 surface.every(BLINK_MS, () => {
158 const held = now()
159 if (held.hasKeys) put({ ...held, isBlinkOff: !held.isBlinkOff })
160 })
161 }
162
163 surface.onKey(raw => {
164 // Arrow and other named keys as a browser names them (ArrowRight, End) are read as the engine names them.
165 const named = /^Arrow(Up|Down|Left|Right)$/.exec(raw.key)?.[1]
166 const key = named !== undefined ? { ...raw, key: named.toLowerCase() } : /^(End|Home|Enter|Tab|Backspace|Delete)$/.test(raw.key) ? { ...raw, key: raw.key.toLowerCase() } : raw
167 const state = now()
168 // Ctrl+C as a surface may hand it: the letter in either case, or the control character it types.
169 const isInterrupt = (key.ctrl === true && key.key.toLowerCase() === 'c') || key.key === '\u0003' || (key.ctrl === true && key.key === 'break')
170 // Ctrl+A selects the whole line, as PSReadLine does in a Windows PowerShell window.
171 if ((key.ctrl === true && key.key.toLowerCase() === 'a') || key.key === '\u0001') {
172 if (textOf(state.buffer) !== '') set({ isAllSelected: true, cycle: null })
173
174 return
175 }
176 if (state.isAllSelected === true) {
177 const whole = textOf(state.buffer)
178 // With a selection, ctrl+c copies it rather than interrupting.
179 if (isInterrupt) {
180 surface.post({ type: 'copy', termId: props.termId, text: whole })
181 set({})
182
183 return
184 }
185 if (key.key === 'backspace' || key.key === 'delete') {
186 set({ buffer: EMPTY, back: -1, cycle: null })
187
188 return
189 }
190 const text = typed(key)
191 if (text !== undefined) {
192 set({ buffer: bufferOf(text, 0, text.length), back: -1, cycle: null })
193
194 return
195 }
196 if (key.key === 'left' || key.key === 'home') {
197 set({ buffer: bufferOf(whole, 0, 0) })
198
199 return
200 }
201 if (key.key === 'right' || key.key === 'end') {
202 set({ buffer: bufferOf(whole, 0, whole.length) })
203
204 return
205 }
206 // A paste replaces the selection: the line empties now, and the clipboard's text goes in when it comes.
207 const isPasteKey = ((key.ctrl === true || key.meta === true) && key.key.toLowerCase() === 'v') || (key.shift === true && key.key === 'insert')
208 if (isPasteKey) put({ ...state, buffer: EMPTY, isAllSelected: false })
209 }
210 if (isInterrupt) {
211 if (props.isBusy) surface.post({ type: 'stop', termId: props.termId })
212 set({ buffer: EMPTY, back: -1, cycle: null })
213
214 return
215 }
216 // Paste: the clipboard is the hooks module's to read; it answers with the text in `paste`.
217 const isPaste = ((key.ctrl === true || key.meta === true) && key.key.toLowerCase() === 'v') || (key.shift === true && key.key === 'insert')
218 if (isPaste) {
219 surface.post({ type: 'paste', termId: props.termId, seq: Date.now() % 1_000_000_000 })
220 set({})
221
222 return
223 }
224 if ((key.ctrl === true && key.key.toLowerCase() === 'l') || key.key === '\u000c') {
225 surface.post({ type: 'clear', termId: props.termId })
226 set({})
227
228 return
229 }
230 if (key.key === 'return' || key.key === 'enter') {
231 const line = textOf(state.buffer)
232 if (props.isEnded) {
233 surface.post({ type: 'restart', termId: props.termId })
234 set({})
235
236 return
237 }
238 if (props.isBusy) {
239 set({ note: 'still running: ctrl+c stops it' })
240
241 return
242 }
243 surface.post({ type: 'run', termId: props.termId, text: line })
244 set({ buffer: EMPTY, back: -1, draft: '', cycle: null })
245
246 return
247 }
248 if (key.key === 'tab') {
249 const cycle = state.cycle
250 if (cycle !== null && cycle.items.length > 1) {
251 const step = key.shift === true ? -1 : 1
252 const index = (cycle.index + step + cycle.items.length) % cycle.items.length
253 set({ cycle: { ...cycle, index }, buffer: complete(cycle.base, cycle, cycle.items[index] ?? '') })
254
255 return
256 }
257 if (props.isBusy || cycle !== null) return
258 const seq = Date.now() % 1_000_000_000
259 surface.post({ type: 'complete', termId: props.termId, text: textOf(state.buffer), cursor: state.buffer.col, seq })
260 set({ askedSeq: seq })
261
262 return
263 }
264 if (key.key === 'up' || key.key === 'down') {
265 const list = props.history
266 if (list.length === 0) return
267 const back = key.key === 'up' ? Math.min(list.length - 1, state.back + 1) : state.back - 1
268 if (back < 0) {
269 set({ back: -1, buffer: bufferOf(state.draft, 0, state.draft.length), cycle: null })
270
271 return
272 }
273 const shown = list[list.length - 1 - back] ?? ''
274 set({
275 back,
276 draft: state.back === -1 ? textOf(state.buffer) : state.draft,
277 buffer: bufferOf(shown, 0, shown.length),
278 cycle: null,
279 })
280
281 return
282 }
283 // A suggestion is taken as PSReadLine takes one: → or End at the end of the line takes
284 // it all, ctrl+→ the next word of it.
285 const typedLine = textOf(state.buffer)
286 const offered = state.buffer.col === typedLine.length && state.back === -1 && state.cycle === null ? suggest(typedLine, props.history) : ''
287 if (offered !== '' && key.ctrl !== true && (key.key === 'right' || key.key === 'end')) {
288 set({ buffer: bufferOf(typedLine + offered, 0, typedLine.length + offered.length) })
289
290 return
291 }
292 if (offered !== '' && key.ctrl === true && key.key === 'right') {
293 const word = /^\s*\S+/.exec(offered)?.[0] ?? offered
294 set({ buffer: bufferOf(typedLine + word, 0, typedLine.length + word.length) })
295
296 return
297 }
298 const next = edit(state.buffer, key, false)
299 if (next !== undefined) set({ buffer: next, cycle: null, back: -1 })
300 })
301
302 const line = state.buffer.lines[0] ?? ''
303 const col = state.buffer.col
304 // On a cell grid a line wider than its room scrolls with the cursor; set in proportional
305 // type, where a cell count says little, it wraps under the prompt as a terminal's does.
306 const room = props.isProportional ? Infinity : Math.max(10, (surface.columns > 0 ? surface.columns : 60) - props.prompt.length - 2)
307 const left = col >= room ? col - room + 1 : 0
308 const show = (text: string): string => (props.isProportional ? keepSpaces(text) : text)
309 const before = show(line.slice(left, col))
310 const under = line[col] ?? ' '
311 const after = show(line.slice(col + 1, left + room))
312 const cycle = state.cycle
313 // The rest of the latest command run here that starts as this line does, in grey after
314 // the cursor, as PowerShell 7's prediction shows it; the cursor sits on its first letter.
315 const ghost =
316 col === line.length && state.back === -1 && cycle === null && state.isAllSelected !== true && !props.isBusy && !props.isEnded ? suggest(line, props.history) : ''
317 const ghostShown = ghost.slice(0, Math.max(0, left + room - col))
318 const hint = props.isEnded
319 ? 'session ended: Enter restarts it'
320 : state.note !== ''
321 ? state.note
322 : props.isBusy
323 ? 'ctrl+c stops'
324 : ''
325
326 // The region is the whole screen, so a click anywhere on it takes the keys: what the
327 // session printed, the prompt after it as PowerShell prints it (and after that, quietly,
328 // the completions to pick from or a note), then the empty black below.
329 return (
330 <Box flexDirection="column" width="100%" flexGrow={1}>
331 {props.screen.map(row =>
332 // Set in proportional type, block characters would not line up and a QR code would not
333 // scan: each is drawn as one cell instead, its top and bottom halves filled as the glyph is.
334 props.isProportional && isBlockArt(row) ? (
335 <Box flexDirection="row">
336 {row.flatMap(span =>
337 [...span.text].map(glyph => (
338 <Box flexDirection="column" width={1} height={1} flexShrink={0}>
339 <Box height={0.5} {...(glyph === '▀' || glyph === '█' ? { backgroundColor: span.color } : {})} />
340 <Box height={0.5} {...(glyph === '▄' || glyph === '█' ? { backgroundColor: span.color } : {})} />
341 </Box>
342 )),
343 )}
344 </Box>
345 ) : (
346 <Box flexDirection="row">
347 {row.length === 0 ? (
348 <Text> </Text>
349 ) : (
350 row.map(span => (
351 <Text color={span.color} italic={span.isItalic === true} bold={span.isBold === true} dimColor={span.isDim === true} underline={span.isUnderline === true}>
352 {show(span.text)}
353 </Text>
354 ))
355 )}
356 </Box>
357 ),
358 )}
359 <Box flexDirection="row" flexWrap="wrap">
360 <Text color={props.isEnded ? props.bad : props.accent}>{show(props.prompt)}</Text>
361 {/* Selected with ctrl+a: the whole line highlighted, as a selection in a PowerShell window. */}
362 {state.isAllSelected === true && (
363 <Text inverse color={props.ink}>
364 {show(line.slice(left, left + room))}
365 </Text>
366 )}
367 {state.isAllSelected !== true && <Text color={props.ink}>{before}</Text>}
368 {/* Each piece a child of this row itself, so the suggestion stays on the prompt's line. */}
369 {ghostShown !== '' && (
370 <Text inverse={!state.isBlinkOff} color={props.hint}>
371 {show(ghostShown.slice(0, 1))}
372 </Text>
373 )}
374 {ghostShown !== '' && ghostShown.length > 1 && <Text color={props.hint}>{show(ghostShown.slice(1))}</Text>}
375 {ghostShown === '' && state.isAllSelected !== true && (
376 <Text inverse={!props.isBusy && !state.isBlinkOff} color={props.ink}>
377 {show(under)}
378 </Text>
379 )}
380 {ghostShown === '' && state.isAllSelected !== true && after !== '' && <Text color={props.ink}>{after}</Text>}
381 {cycle !== null && cycle.items.length > 1 ? (
382 <Box flexDirection="row" columnGap={2} marginLeft={2}>
383 {cycle.items.slice(0, 12).map((item, at) => (
384 <Text key={`c${at}`} inverse={at === cycle.index} color={props.hint}>
385 {show(item)}
386 </Text>
387 ))}
388 {cycle.items.length > 12 && <Text color={props.hint}>+{cycle.items.length - 12}</Text>}
389 </Box>
390 ) : (
391 hint !== '' && (
392 <Box marginLeft={2}>
393 <Text color={props.hint}>{hint}</Text>
394 </Box>
395 )
396 )}
397 </Box>
398 {Array.from({ length: Math.max(2, props.minRows - props.screen.length - 1) }, (_, at) => (
399 <Text key={`pad${at}`}> </Text>
400 ))}
401 </Box>
402 )
403}
404
405export default Input
406hooks/views/files.tsx 206 lines1import type { RenderElement } from 'claude-code'
2
3import type { Listing, OpenFile } from '../../types'
4import { base, dir, isUnder, join, rel } from '../paths'
5import { G, fit, fitStart, inView, plural } from './chrome'
6import type { Actions, Kit, Model } from './model'
7import { action, card, emptyState, icon, row, section, tile, tone, toolbar } from './ui'
8
9/** Lines of a file shown at a time while reading it. */
10export const VIEW_LINES = 120
11const MAX_ENTRIES = 300
12
13const sizeOf = (bytes: number): string =>
14 bytes < 1024 ? `${bytes} B` : bytes < 1048576 ? `${Math.round(bytes / 1024)} KB` : `${(bytes / 1048576).toFixed(1)} MB`
15
16/** Where Files is rooted: the repository in view, else the session's folder. */
17export const filesRoot = (m: Model): string => inView(m)?.root ?? m.workspace
18
19/** The folder's path from the root, each part a press back up to it. */
20function crumbs(kit: Kit, m: Model, listing: Listing, act: Actions): RenderElement {
21 const { Box, Text, Button } = kit
22 const root = filesRoot(m)
23 const parts = rel(listing.path, root).split('/').filter(part => part !== '')
24
25 return (
26 <Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={0.5} marginTop={0.75}>
27 {icon(kit, m, 'folder', 'blue', 14)}
28 <Button key="crumb-root" plain dimColor={parts.length > 0} label={base(root)} onPress={() => act.openDir(root)} />
29 {parts.map((part, at) => (
30 <Box flexDirection="row" alignItems="center" columnGap={0.5}>
31 <Text dimColor>{G.closed}</Text>
32 <Button
33 key={`crumb-${at}`}
34 plain
35 dimColor={at < parts.length - 1}
36 label={part}
37 onPress={() => act.openDir(join(root, ...parts.slice(0, at + 1)))}
38 />
39 </Box>
40 ))}
41 </Box>
42 )
43}
44
45function searchView(kit: Kit, m: Model, act: Actions): RenderElement | false {
46 const { Box, Text } = kit
47 const found = m.search
48 if (found === null) return false
49 const root = filesRoot(m)
50
51 return (
52 <Box flexDirection="column">
53 {section(
54 kit,
55 m,
56 `Results for “${fit(found.query, 24)}”`,
57 `${plural(found.names.length, 'file')} · ${plural(found.hits.length, 'line')}${found.isCut ? ' (first ones)' : ''}`,
58 action(kit, m, 'search-clear', 'close', 'Clear', () => act.clearSearch(), 'gray'),
59 )}
60 {found.names.length === 0 && found.hits.length === 0 && card(kit, m, [<Text dimColor>Nothing matches.</Text>])}
61 {found.names.length > 0 &&
62 card(
63 kit,
64 m,
65 found.names.slice(0, 40).map(path =>
66 row(kit, m, {
67 key: `name:${path}`,
68 lead: icon(kit, m, 'doc', 'gray', 14),
69 inline: true,
70 title: base(path),
71 sub: dir(path) === '' ? false : fitStart(dir(path), Math.max(12, m.width - 12)),
72 onPress: () => act.openFile(join(root, path)),
73 }),
74 ),
75 { dense: true },
76 )}
77 {found.hits.length > 0 &&
78 card(
79 kit,
80 m,
81 found.hits.slice(0, 80).map(hit =>
82 row(kit, m, {
83 key: `hit:${hit.path}:${hit.line}`,
84 lead: <kit.Text dimColor>{`${fitStart(hit.path, 24)}:${hit.line}`}</kit.Text>,
85 inline: true,
86 title: fit(hit.text.trim() || ' ', Math.max(12, m.width - 34)),
87 onPress: () => act.openFile(join(root, hit.path), hit.line),
88 }),
89 ),
90 { dense: true },
91 )}
92 </Box>
93 )
94}
95
96/** A folder's entries, folders first, and the search over the whole tree. */
97export function browser(kit: Kit, m: Model, act: Actions): RenderElement {
98 const { Box, Text, Input } = kit
99 const listing = m.listing
100 const root = filesRoot(m)
101 if (root === '') return emptyState(kit, m, 'folder', 'blue', 'No folder', 'This session has no working folder.')
102
103 return (
104 <Box flexDirection="column">
105 {Input !== undefined && (
106 <Box flexDirection="row" alignItems="center" columnGap={1} marginTop={0.75}>
107 {icon(kit, m, 'search', 'gray', 13)}
108 <Box flexGrow={1}>
109 <Input key="files-search" placeholder="a file's name, or words in files" submitLabel="search" onSubmit={text => act.searchFiles(text)} />
110 </Box>
111 </Box>
112 )}
113 {searchView(kit, m, act)}
114 {m.search === null && listing === null && (
115 <Box marginTop={1} {...(m.isGrid ? {} : { paddingX: 1 })}>
116 <Text dimColor>Reading the folder…</Text>
117 </Box>
118 )}
119 {m.search === null && listing !== null && (
120 <Box flexDirection="column">
121 {crumbs(kit, m, listing, act)}
122 {card(
123 kit,
124 m,
125 [
126 listing.entries.length === 0 && <Text dimColor>An empty folder.</Text>,
127 isUnder(listing.path, root) &&
128 rel(listing.path, root) !== '' &&
129 row(kit, m, { key: 'dir-up', lead: icon(kit, m, 'back', 'gray', 14), title: '..', inline: true, onPress: () => act.openDir(dir(listing.path)) }),
130 ...listing.entries.slice(0, MAX_ENTRIES).map(entry =>
131 entry.isDir
132 ? row(kit, m, {
133 key: `dir:${entry.name}`,
134 lead: icon(kit, m, 'folder', 'blue', 14),
135 inline: true,
136 title: entry.name,
137 onPress: () => act.openDir(join(listing.path, entry.name)),
138 chevron: 'right',
139 })
140 : row(kit, m, {
141 key: `file:${entry.name}`,
142 lead: icon(kit, m, 'doc', 'gray', 14),
143 inline: true,
144 title: fit(entry.name, Math.max(10, m.width - 18)),
145 trail: [<Text dimColor>{sizeOf(entry.size)}</Text>],
146 onPress: () => act.openFile(join(listing.path, entry.name)),
147 }),
148 ),
149 listing.entries.length > MAX_ENTRIES && <Text dimColor>{`+${listing.entries.length - MAX_ENTRIES} more`}</Text>,
150 ],
151 { dense: true },
152 )}
153 </Box>
154 )}
155 </Box>
156 )
157}
158
159/** One file: read a window of it at a time, or edit it where the surface runs the editor. */
160export function fileView(kit: Kit, m: Model, file: OpenFile, act: Actions): RenderElement {
161 const { Box, Text, Code } = kit
162 const lines = file.text.split('\n')
163 const canEdit = kit.editor !== undefined && file.note === ''
164 const top = Math.max(0, Math.min(file.top, Math.max(0, lines.length - 1)))
165 const end = Math.min(lines.length, top + VIEW_LINES)
166 const where = rel(dir(file.path), filesRoot(m))
167
168 return (
169 <Box flexDirection="column">
170 {card(kit, m, [
171 row(kit, m, {
172 key: 'file-head',
173 lead: tile(kit, m, file.isEditing ? 'pencil' : 'doc', file.isEditing ? 'orange' : 'blue', 26),
174 title: base(file.path),
175 isBold: true,
176 sub: `${where === '' ? '' : `${where} · `}${file.isEditing ? 'Editing' : file.note === '' ? `Lines ${top + 1}–${end} of ${lines.length}` : 'Not shown'}`,
177 }),
178 toolbar(kit, [
179 file.isEditing
180 ? action(kit, m, 'file-back', 'check', 'Done', () => act.toggleEdit(), 'blue', true)
181 : action(kit, m, 'file-back', 'back', 'Back', () => act.closeFile(), 'blue'),
182 canEdit && !file.isEditing && action(kit, m, 'file-edit', 'pencil', 'Edit', () => act.toggleEdit(), 'orange'),
183 action(kit, m, 'file-at', 'at', 'Mention', () => act.mention(file.path), 'gray'),
184 !file.isEditing && file.note === '' && top > 0 && action(kit, m, 'file-prev', 'up', 'Previous', () => act.pageFile(-1), 'gray'),
185 !file.isEditing && file.note === '' && end < lines.length && action(kit, m, 'file-next', 'down', 'Next', () => act.pageFile(1), 'gray'),
186 ]),
187 ])}
188 <Box flexDirection="column" marginTop={0.5}>
189 {file.note !== '' && card(kit, m, [<Text color={tone(m, 'orange')}>{file.note}</Text>])}
190 {file.note === '' && file.isEditing && kit.editor !== undefined &&
191 card(kit, m, [
192 kit.editor({
193 path: file.path,
194 text: file.text,
195 rev: file.mtimeMs,
196 rows: m.rows > 0 ? m.rows - 6 : 30,
197 isProportional: !m.isGrid,
198 accent: tone(m, 'blue'),
199 }),
200 ])}
201 {file.note === '' && !file.isEditing && <Code source={lines.slice(top, end).join('\n') || ' '} startLine={top + 1} path={file.path} />}
202 </Box>
203 </Box>
204 )
205}
206