SLOPSHOPPER

workbench

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

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-13hymleong/claude-mods/plugins/workbench
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · workbench
│ ┃ Workbench ✕ › fix the failing auth test and add an audit log call │ ┃ ✓ Workbench │ ┃ ● Done! ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ Git ] 2: Terminals 3: Ports ⏺ Update(src/auth.ts) │ ┃ ─────────────────────────────────────────… ⎿ Added 2 lines, removed 1 line │ ┃ [ Changes ] f: Files h: History r: PRs ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ⎇ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ No Git repository here │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Open Claude Code in a repository, or in a │ ┃ folder that holds some. › /workbench │ ┃ │ ┃ │ ┃ ─────────────────────────────────────────… │ ┃ 1: git 2: terminals 3: ports │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Workbench
✓ Workbench ● Done! [ Git ] 2: Terminals 3: Ports ────────────────────────────────────────────────────── [ Changes ] f: Files h: History r: PRs a: Actions ⎇ No Git repository here Open Claude Code in a repository, or in a folder that holds some. ────────────────────────────────────────────────────── 1: git 2: terminals 3: ports
README

claude-mods

Small Claude Code mods, shipped as plugins from one marketplace.

PluginWhat it does
status-hudA bar above the prompt: current activity, 5-hour limit, weekly limit and context window.
CacheA status-line countdown (Cache ⏱ 52:10) of the 1 hour prompt-cache window, counted from the last reply.
workbenchA side pane (/workbench) with Git review, several real PowerShell terminals, and a live monitor of the servers listening on localhost.

Install

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

/workbench opens the pane. It has three views:

  • Git: changes, staged and unstaged, with per-file diffs; stage, unstage and commit; fetch, pull and push. Also a file browser with search and a simple editor, the commit graph, and the branch's pull requests and Actions runs. PRs and Actions use the GitHub CLI if it is installed.
  • Terminals: real PowerShell sessions. Variables, functions and the folder you 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.
  • Ports: what is listening on this machine, dev servers first. It shows the framework, page title and response time, and links each port to the terminal that started it. You can open a port in the browser or stop its process.

/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.

Notes

  • Cache keeps its data on your machine only (in Claude Code's plugin store). It does not send anything anywhere.
  • To update later: claude plugin marketplace update claude-mods, then claude plugin update <name>@claude-mods.

License

MIT, see LICENSE.

Source 25 files
hooks/register.tsx 1981 lines
1import { 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 lines
1import 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
138
hooks/git.ts 385 lines
1import 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.'
385
hooks/paths.ts 78 lines
1// 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}
78
hooks/ports.ts 361 lines
1import 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}
361
hooks/scripts.ts 132 lines
1import 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)
132
hooks/mood.ts 27 lines
1import 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
27
hooks/shell-host.ts 374 lines
1// 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}
374
hooks/stream.ts 216 lines
1// 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}
216
hooks/tab-name.ts 157 lines
1// 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}
157
hooks/terminal-input.tsx 406 lines
1import 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
406
hooks/views/files.tsx 206 lines
1import 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