SLOPSHOPPER

here

A HERE band above the prompt that says where the session is, with no model call by default (option rewrite=haiku makes one per answer): the goal (goal-meter's…

newbandspinnerguardcommandprompt
v0.1.0MITupdated 2026-10-20ZH-L1N/agent-window-mods/here
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · here
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /here ⎿ here: goal - ⎿ here: now Running cat ⎿ here: you - ◉ HERE now Running cat ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◉ HERE now Running cat
README

here

A ◉ HERE row above the prompt that says where the session is, with no model call by default:

 ◉ HERE     goal Build the mods · now Editing agents/hooks/kit.ts · you: "Should I ship it?" +1
 ◉ HERE     now Sauteing…                                  (thinking, before the turn's first tool call)
 ◉ HERE     you: "Say go and I will merge it."              (only a question left)
  • goal: the goal-meter mod's goal name (its goal-meter.goal state) while its task list is open; else the first line of the prompt that started the main loop's open task list (TodoWrite, or TaskCreate and TaskUpdate), kept until every item of it is done; else nothing.
  • now: the main loop's last tool call as a verb and a path relative to the session (Editing agents/hooks/kit.ts, Reading README.md, Running uv run pytest, Searching for "stackRow", Fetching docs.example.com), recorded as the call starts; before a turn's first tool call, the terminal spinner's word (Sauteing…), which leaves with the turn. Subagents' calls are not counted.
  • you: the question lines of the last main-loop reply: lines ending in ?, numbered items under a "questions" heading, and "say go" lines (code blocks skipped, at most five kept). Control characters are dropped from every part. The row shows the first, cut to 60 columns, in orange, with +n for the rest. Dropped when you send the next prompt (a slash command does not drop them).
  • Shown whenever any of the three has a value; hidden otherwise. On a narrow band the goal is cut first (40, then 20 columns), then now (48, then 28); the question is kept.
  • Stack: the row is keyed zm-70-here and takes its slot in the zehui-mods stack order (SYS 10, PLAN 20, HANDOFF 30, CACHE 40, AGENTS 50, GOAL 60, HERE 70, NEXT 80); other bands follow. It yields to a survey and draws on the terminal only.

/here

CommandWhat it does
/herePrints the three parts, one line each, every kept question under you.
/here on / offShows or hides the row (kept in the store across sessions).

The row has no buttons, so nothing in it needs the band focused.

Options

rewrite: off (default) or haiku. With haiku, one Haiku call ($.model.complete, effort low, 300 tokens, 20 s limit) after each main-loop answer rewrites the three parts from the reply's last 8,000 characters and goal-meter's task list; the row and /here use what it said, falling back to the plain parts where it said nothing. The call runs from a timer after the turn, is skipped while the row is off, and its answer is dropped once you send the next prompt or the next main-loop turn starts.

Install (local)

claude plugin install here@zehui-mods --scope user

Notes

  • No network and no processes: the mod reads its own hooks' events and goal-meter's state. A model is called only with rewrite=haiku.
  • The spinner word is seen only while the engine draws the Spinner, and a render may not write state ("drawing is pure", host check), so the word is kept in the module (hooks/live.ts) and the Spinner hook asks for one redraw when it changes.

See PROVENANCE.md for where it came from.

Source 5 files
hooks/register.tsx 245 lines
1// The here mod: the ◉ HERE row (row.tsx) says where the session is, with no
2// model call by default. goal: goal-meter's goal name (read from its state),
3// else the first line of the prompt that started the open task list;
4// now: the main loop's last tool call as a verb and a path, or the spinner
5// word until the turn's first tool call; you: the question lines of the last
6// main-loop reply, dropped when the next prompt is sent. /here prints the
7// three, /here on|off shows or hides the row. With the option rewrite=haiku,
8// one Haiku call after each main-loop answer rewrites the three parts; it
9// runs from a $.clock timer, never inside a hook.
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, PluginOptions, Register } from 'claude-code'
12
13import type { HereNow } from '../types'
14import {
15  COMMAND,
16  QUESTION_KEEP,
17  REWRITE_DELAY_MS,
18  REWRITE_MAX_TOKENS,
19  REWRITE_MODEL,
20  REWRITE_SYSTEM,
21  REWRITE_TIMEOUT_MS,
22  STORE_BAND,
23  firstLineOf,
24  isBandOn,
25  listGoalOf,
26  nowOf,
27  parseRewrite,
28  partsOf,
29  questionsOf,
30  rewriteOf,
31  rewritePrompt,
32  summaryOf,
33} from './kit'
34import { live } from './live'
35import { drawRow, nowText, peerGoal } from './row'
36
37const now = atom({ plugin: 'here', key: 'now' } as const, null)
38const you = atom({ plugin: 'here', key: 'you' } as const, [])
39const band = atom({ plugin: 'here', key: 'band' } as const, '')
40const rewrite = atom({ plugin: 'here', key: 'rewrite' } as const, null)
41const goal = atom({ plugin: 'here', key: 'goal' } as const, null)
42
43// The session's directory, for relative paths in `now`; read at session.start.
44let cwd = ''
45// Moves on every prompt sent and every main-loop turn start: a rewrite that
46// finishes after either is about an answer the row no longer shows, and is dropped.
47let generation = 0
48// The first line of the last prompt the person sent: the fallback goal of a task list it starts.
49let lastPrompt = ''
50const LIST_TOOLS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate'])
51
52/** Writes a /here setting to the store; the words to add to the reply when it could not be kept. */
53async function persist($: EngineInterface, key: string, value: unknown): Promise<string> {
54  try {
55    await $.store.set(key, value)
56    return ''
57  } catch {
58    return ' (not saved for the next session)'
59  }
60}
61
62/** One Haiku call that rewrites the three parts from the answer and the task list; best effort. */
63async function runRewrite($: EngineInterface, answer: string, turnId: string, gen: number): Promise<void> {
64  // A prompt was sent before the timer fired: the answer is no longer shown, so no call.
65  if (gen !== generation) return
66  const goal = await peerGoal($)
67  const n = await read($, now)
68  const qs = await read($, you)
69  const r = await $.model.complete({
70    model: REWRITE_MODEL,
71    system: REWRITE_SYSTEM,
72    prompt: rewritePrompt(answer, goal.tasks, { goal: goal.name, now: n?.text ?? '', you: qs[0] ?? '' }),
73    maxTokens: REWRITE_MAX_TOKENS,
74    effort: 'low',
75    timeoutMs: REWRITE_TIMEOUT_MS,
76  })
77  if (!r.isAnswered || gen !== generation) return
78  const parts = parseRewrite(r.text)
79  if (!parts) return
80  await update($, rewrite, () => ({ ...parts, turnId }))
81}
82
83const USAGE = `Usage: /${COMMAND} [on | off]`
84
85export const register: Register = (on, options: PluginOptions) => {
86  on('session.start', async ($, e, next) => {
87    const started = await next(e)
88    cwd = typeof e.cwd === 'string' ? e.cwd : ''
89    live.isTurnRunning = false
90    live.spinnerWord = ''
91    await $.command.register({
92      name: 'here',
93      description: 'HERE row: print the goal, what Claude is doing now and the questions the last reply asked; or turn the row on or off',
94      argumentHint: 'on | off',
95    })
96    try {
97      const stored = await $.store.get(STORE_BAND)
98      await update($, band, () => (stored === 'on' || stored === 'off' ? stored : ''))
99    } catch {
100      // No store: the row is on.
101    }
102    return started
103  })
104
105  // A main-loop turn starts: the last turn's `now` and rewrite are done with; the spinner word stands in until the first tool call.
106  on('turn.start', async ($, e, next) => {
107    const started = await next(e)
108    if (e.agentId !== undefined) return started
109    live.isTurnRunning = true
110    generation++
111    try {
112      await update($, now, () => null)
113      await update($, rewrite, () => null)
114    } catch {
115      // The row keeps the last `now` until the first tool call.
116    }
117    return started
118  })
119
120  // Each main-loop tool call is `now` from the moment it starts; a task-list
121  // call that went through moves the fallback goal (TaskCreate's id is in its result).
122  on('tool.call', async ($, e, next) => {
123    const input = e as unknown as Record<string, unknown>
124    if (e.agentId === undefined) {
125      try {
126        const text = nowOf(e.tool, input, cwd)
127        const at = await $.clock.now()
128        if (text) await update($, now, (): HereNow => ({ text, at }))
129      } catch {
130        // `now` is best effort: the call goes on whatever happens here.
131      }
132    }
133    const ran = await next(e)
134    if (e.agentId === undefined && LIST_TOOLS.has(e.tool) && ran.deny === undefined && !ran.isError) {
135      try {
136        await update($, goal, prev => listGoalOf(prev, e.tool, input, ran.result, lastPrompt))
137      } catch {
138        // The goal is best effort: the tool's result goes on whatever happens here.
139      }
140    }
141    return ran
142  }).catch(($, e, next) => (next.called ? undefined : next(e)))
143
144  // The spinner's word, kept for the row until the turn's first tool call. Drawing
145  // is pure: the word goes to the module (live.ts), never to $.state, and one
146  // redraw is asked only when it changed, so the row picks it up. The tree is
147  // the engine's, untouched.
148  on('ui.render', { component: 'Spinner' }, ($, e, next) => {
149    try {
150      const word = e.surface === 'terminal' && typeof e.props.word === 'string' ? e.props.word.trim() : ''
151      if (word && word !== live.spinnerWord) {
152        live.spinnerWord = word
153        if (live.isTurnRunning) $.ui.invalidate('ui.render')
154      }
155    } catch {
156      // The spinner draws as the engine has it whatever happens here.
157    }
158    return next(e)
159  })
160
161  // A main-loop answer: its question lines are `you`; the spinner word goes with the turn.
162  on('turn.complete', async ($, e, next) => {
163    const done = await next(e)
164    if (e.agentId !== undefined) return done
165    live.isTurnRunning = false
166    live.spinnerWord = ''
167    try {
168      const answer = typeof e.answer === 'string' ? e.answer : ''
169      const qs = questionsOf(answer)
170      await update($, you, () => qs)
171      await update($, rewrite, () => null)
172      if (answer.trim() && rewriteOf(options.rewrite) === 'haiku' && isBandOn(await read($, band))) {
173        const gen = generation
174        const turnId = e.turnId
175        // The call runs from a timer, never inside this hook.
176        $.clock.after(REWRITE_DELAY_MS, () => {
177          void runRewrite($, answer, turnId, gen).catch(() => {})
178        })
179      }
180    } catch {
181      // The row keeps what it had.
182    }
183    return done
184  })
185
186  // A prompt the person sends (not a slash command, not a hidden one) answers the questions: `you` is dropped.
187  on('prompt.submit', async ($, e, next) => {
188    const r = await next(e)
189    try {
190      if (!e.command && !e.isMeta && r.drop === undefined) {
191        generation++
192        lastPrompt = firstLineOf(e.text)
193        await update($, you, () => [])
194        await update($, rewrite, () => null)
195      }
196    } catch {
197      // The questions stay until the next answer replaces them.
198    }
199    return r
200  }).catch(($, e, next) => (next.called ? undefined : next(e)))
201
202  // A /clear or an in-process resume puts another conversation in this process.
203  on('session.end', async ($, e, next) => {
204    const result = await next(e)
205    if (e.reason === 'clear' || e.reason === 'resume') {
206      live.isTurnRunning = false
207      live.spinnerWord = ''
208      generation++
209      lastPrompt = ''
210      try {
211        await update($, now, () => null)
212        await update($, you, () => [])
213        await update($, rewrite, () => null)
214        await update($, goal, () => null)
215      } catch {
216        // Nothing more to do.
217      }
218    }
219    return result
220  })
221
222  // The command and its matcher are spelled literally, so the host reads it as answering its own command.
223  on('command.run', { command: 'here' }, async ($, e) => {
224    const [verb = ''] = e.args.trim().toLowerCase().split(/\s+/).filter(Boolean)
225    switch (verb) {
226      case '': {
227        const rw = rewriteOf(options.rewrite) === 'haiku' ? await read($, rewrite) : null
228        const qs = (await read($, you)).map(q => q.slice(0, QUESTION_KEEP))
229        const p = partsOf((await peerGoal($)).name, nowText(await read($, now)), qs, rw)
230        return { text: summaryOf(p, isBandOn(await read($, band))) }
231      }
232      case 'on':
233      case 'off': {
234        await update($, band, () => verb)
235        const note = await persist($, STORE_BAND, verb)
236        return { text: `${verb === 'on' ? 'HERE row on.' : `HERE row off (/${COMMAND} still prints the three parts).`}${note}` }
237      }
238      default:
239        return { text: USAGE }
240    }
241  })
242
243  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => drawRow($, e, () => next(e), options))
244}
245
hooks/kit.ts 439 lines
1// Shared by register.tsx and row.tsx: the look, how a tool call reads as
2// "now", which lines of a reply are questions for the person, the Haiku
3// rewrite's request and answer, and the stack-order helper. Pure functions
4// only; the state atoms are declared again in each file that reads or writes
5// them (the engine reads a state source only from a const of the calling file).
6import type { RenderElement, RenderNode } from 'claude-code'
7
8import type { HereGoal } from '../types'
9import type { PeerGoal } from '../types/peer'
10
11export const CHIP = ' ◉ HERE    ' // padded to the widest zehui-mods chip (HANDOFF) so the colour bars line up
12export const CHIP_FG = '#0b0b0f'
13export const CHIP_BG = '#fb923c'
14export const COMMAND = 'here'
15export const STORE_BAND = 'here.band'
16/** The question shown in the row is cut to this many terminal columns. */
17export const YOU_MAX = 60
18/** A question line is kept this long in state (/here prints it whole up to this). */
19export const QUESTION_KEEP = 200
20/** At most this many question lines are kept from one reply. */
21export const QUESTIONS_MAX = 5
22/** What `now` is cut to in the row, and the goal's name. */
23export const NOW_MAX = 48
24export const GOAL_MAX = 40
25/** On a narrow band the goal and now are cut further, in this order. */
26export const GOAL_SHORT = 20
27export const NOW_SHORT = 28
28
29/** The rewrite (option rewrite=haiku): the reply's tail sent, the reply's cap, the time limit, the delay after the turn. */
30export const REWRITE_MODEL = 'haiku'
31export const REWRITE_REPLY_MAX = 8_000
32export const REWRITE_MAX_TOKENS = 300
33export const REWRITE_TIMEOUT_MS = 20_000
34export const REWRITE_DELAY_MS = 10
35
36// --- terminal columns
37
38/** East Asian Wide and Fullwidth ranges (CJK, Hangul, fullwidth forms) and emoji: two columns each. */
39const WIDE: readonly (readonly [number, number])[] = [
40  [0x1100, 0x115f],
41  [0x2e80, 0xa4cf],
42  [0xac00, 0xd7a3],
43  [0xf900, 0xfaff],
44  [0xfe30, 0xfe4f],
45  [0xff00, 0xff60],
46  [0xffe0, 0xffe6],
47  [0x1f300, 0x1faff],
48  [0x20000, 0x3fffd],
49]
50
51/** The columns one character takes in the terminal: 2 for a wide one, else 1. */
52export function charCells(ch: string): number {
53  const cp = ch.codePointAt(0) ?? 0
54  return WIDE.some(([lo, hi]) => cp >= lo && cp <= hi) ? 2 : 1
55}
56
57/** The columns a string takes in the terminal. */
58export const cellsOf = (s: string): number => [...s].reduce((w, ch) => w + charCells(ch), 0)
59
60/** Control characters (C0, DEL, C1) as spaces: none of them reaches the row, /here or the state. */
61// eslint-disable-next-line no-control-regex
62export const clean = (s: string): string => s.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
63
64/** Control characters dropped, whitespace folded to single spaces, cut to `max` terminal columns (an ellipsis marks the cut). */
65export function cut(text: string, max: number): string {
66  const flat = clean(text).replace(/\s+/g, ' ').trim()
67  if (cellsOf(flat) <= max) return flat
68  let kept = ''
69  let width = 0
70  for (const ch of flat) {
71    const w = charCells(ch)
72    if (width + w > max - 1) break
73    kept += ch
74    width += w
75  }
76  return `${kept.trimEnd()}…`
77}
78
79// --- now: what a tool call is doing
80
81const str = (v: unknown): string => (typeof v === 'string' ? v : '')
82
83/** A path shown relative to `cwd` when under it; otherwise its last three parts after `…/` when it is long. */
84export function relPath(path: string, cwd: string): string {
85  const root = cwd.replace(/\/+$/, '') + '/'
86  if (cwd && path.startsWith(root)) return path.slice(root.length) || '.'
87  if (cwd && path === cwd.replace(/\/+$/, '')) return '.'
88  const parts = path.split('/').filter(Boolean)
89  return path.startsWith('/') && parts.length > 3 ? `…/${parts.slice(-3).join('/')}` : path
90}
91
92const base = (w: string): string => w.slice(w.lastIndexOf('/') + 1)
93/** A command word that only sets up the one after it. */
94const PREFIX = new Set(['sudo', 'env', 'time', 'command', 'nohup', 'exec', 'nice'])
95/** Programs named with their subcommand (`git status`, `uv run pytest`). */
96const WITH_SUB = new Set([
97  'git', 'gh', 'docker', 'kubectl', 'brew', 'cargo', 'go', 'npm', 'pnpm', 'yarn', 'bun', 'bunx', 'npx', 'uv', 'uvx',
98  'claude', 'herdr', 'make', 'pip', 'pip3', 'poetry', 'just',
99])
100const INTERPRETERS = new Set(['python', 'python3', 'node', 'deno', 'ruby', 'bash', 'sh', 'zsh', 'fish'])
101/** Options of those programs that take the next word as their value (`git -C dir status`). */
102const VALUED = new Set(['-C', '-c', '--git-dir', '--work-tree', '--directory', '--project', '--manifest-path', '-R', '--repo', '-f', '--file'])
103
104/**
105 * The program a Bash command runs, as a few words: its first command that is
106 * not a `cd`, with environment assignments and `sudo`-like prefixes dropped,
107 * plus the subcommand for git, npm, uv and the like (`uv run pytest`), the
108 * script for an interpreter (`python3 build.py`, `python3 -m pytest`).
109 */
110export function programOf(command: string): string {
111  const first = command.split('\n').find(l => l.trim() !== '') ?? ''
112  const segments = first.split(/&&|\|\||;|\|/).map(s => s.trim()).filter(Boolean)
113  const seg = segments.find(s => !/^cd(\s|$)/.test(s)) ?? segments[0] ?? ''
114  const words = seg.split(/\s+/).filter(Boolean).map(w => w.replace(/^['"]|['"]$/g, ''))
115  let i = 0
116  while (i < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i] ?? '') || PREFIX.has(words[i] ?? ''))) i++
117  const prog = base(words[i] ?? '')
118  if (!prog) return ''
119  const rest = words.slice(i + 1)
120  const plain = rest.filter(w => !w.startsWith('-'))
121  if (WITH_SUB.has(prog)) {
122    const plain = rest.filter((w, j) => !w.startsWith('-') && !VALUED.has(rest[j - 1] ?? ''))
123    const sub = plain[0]
124    if (!sub) return prog
125    // `uv run pytest`, `npm run build`, `docker compose up`: one more word.
126    return ['run', 'exec', 'compose', 'x'].includes(sub) && plain[1] ? `${prog} ${sub} ${base(plain[1])}` : `${prog} ${sub}`
127  }
128  if (INTERPRETERS.has(prog)) {
129    const m = rest.indexOf('-m')
130    if (m >= 0 && rest[m + 1]) return `${prog} -m ${rest[m + 1]}`
131    return plain[0] ? `${prog} ${base(plain[0])}` : prog
132  }
133  return prog
134}
135
136/** `https://docs.example.com/a?b` -> `docs.example.com`; the text itself when it is no URL. */
137const hostOf = (url: string): string => /^[a-z][a-z0-9+.-]*:\/\/([^/?#:]+)/i.exec(url)?.[1] ?? url
138
139const TASK_TOOLS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet', 'TaskStop'])
140
141/**
142 * What a main-loop tool call reads as in the row: a verb and its object
143 * (`Editing agents/hooks/kit.ts`, `Running pytest`, `Reading README.md`),
144 * paths relative to the session's directory, control characters dropped. Null
145 * for a call with nothing to say.
146 */
147export function nowOf(tool: string, input: Record<string, unknown>, cwd: string): string | null {
148  const text = nowWords(tool, input, cwd)
149  return text === null ? null : clean(text).replace(/\s+/g, ' ').trim() || null
150}
151
152function nowWords(tool: string, input: Record<string, unknown>, cwd: string): string | null {
153  const path = (key: string) => relPath(str(input[key]), cwd)
154  switch (tool) {
155    case 'Read':
156      return input.file_path ? `Reading ${path('file_path')}` : 'Reading'
157    case 'Edit':
158    case 'MultiEdit':
159      return input.file_path ? `Editing ${path('file_path')}` : 'Editing'
160    case 'Write':
161      return input.file_path ? `Writing ${path('file_path')}` : 'Writing'
162    case 'NotebookEdit':
163      return input.notebook_path ? `Editing ${path('notebook_path')}` : 'Editing a notebook'
164    case 'Grep': {
165      const pattern = cut(str(input.pattern), 24)
166      return pattern ? `Searching for "${pattern}"` : 'Searching'
167    }
168    case 'Glob':
169      return input.pattern ? `Finding ${cut(str(input.pattern), 30)}` : 'Finding files'
170    case 'Bash': {
171      const prog = programOf(str(input.command))
172      return prog ? `Running ${prog}` : 'Running a command'
173    }
174    case 'WebFetch':
175      return input.url ? `Fetching ${hostOf(str(input.url))}` : 'Fetching a page'
176    case 'WebSearch':
177      return input.query ? `Searching the web for "${cut(str(input.query), 24)}"` : 'Searching the web'
178    case 'Agent':
179    case 'Task': {
180      const d = str(input.description)
181      return d ? `Delegating "${cut(d, 30)}"` : 'Delegating to a subagent'
182    }
183    case 'Skill':
184      return input.skill ? `Using skill ${str(input.skill)}` : 'Using a skill'
185    case 'AskUserQuestion':
186      return 'Asking you'
187    case 'ToolSearch':
188      return 'Loading tools'
189  }
190  if (TASK_TOOLS.has(tool)) return 'Updating the task list'
191  const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
192  if (mcp) return `Calling ${mcp[1]} ${mcp[2]}`
193  return tool ? `Using ${tool}` : null
194}
195
196// --- you: the questions the last reply asked
197
198/** A line's text with its Markdown dress taken off: list marks, quote marks, headings, emphasis. */
199function bare(line: string): string {
200  return line
201    .trim()
202    .replace(/^(?:[>#]+\s*)+/, '')
203    .replace(/^[-*+]\s+(?:\[[ xX]\]\s+)?/, '')
204    .replace(/\*\*|__|`/g, '')
205    .trim()
206}
207
208const NUMBERED = /^\(?\d{1,2}[.)]\s+/
209/** A line that asks for the go-ahead: `say go`, `say "go"`, `reply go`. */
210const SAY_GO = /\b(?:say|reply|type|answer)\s+["'\u201c]?go\b/i
211/** A heading or lead-in over a list of questions (`Open questions:`, `## Questions`). */
212const QUESTION_HEAD = /\bquestions?\b.*:?\s*$/i
213
214/** Whether a bare line ends as a question (a trailing `?` or a fullwidth one, maybe inside closing quotes or brackets). */
215const endsAsk = (s: string): boolean => /[?\uff1f][)\]"'\u201d\u2019*_]*$/.test(s)
216
217/**
218 * The question lines of a reply, in order, at most QUESTIONS_MAX, each cut
219 * to QUESTION_KEEP: lines ending in `?`, numbered items under a "questions"
220 * heading, and "say go" lines. Code blocks are skipped; a line is shown
221 * without its list and emphasis marks.
222 */
223export function questionsOf(answer: string): string[] {
224  const out: string[] = []
225  let inCode = false
226  let underHead = false
227  for (const raw of answer.split(/\r?\n/)) {
228    if (/^\s*(```|~~~)/.test(raw)) {
229      inCode = !inCode
230      continue
231    }
232    if (inCode) continue
233    const line = bare(raw)
234    if (!line) continue
235    const isNumbered = NUMBERED.test(line)
236    const isHead = !isNumbered && QUESTION_HEAD.test(line) && (/^\s*#/.test(raw) || /:\s*$/.test(line))
237    if (isHead) {
238      underHead = true
239      if (!endsAsk(line)) continue
240    } else if (!isNumbered && !/^[-*+]\s/.test(raw.trim())) {
241      // A plain paragraph line ends the list a "questions" heading opened.
242      if (underHead && !endsAsk(line)) underHead = false
243    }
244    if (endsAsk(line) || SAY_GO.test(line) || (isNumbered && underHead)) {
245      out.push(cut(line, QUESTION_KEEP))
246      if (out.length >= QUESTIONS_MAX) break
247    }
248  }
249  return out
250}
251
252// --- goal
253
254/** goal-meter's goal name while its list is open; '' without one, or once it is done. */
255export function goalOf(g: PeerGoal | null | undefined): string {
256  if (!g || typeof g !== 'object' || typeof g.name !== 'string') return ''
257  if (!Array.isArray(g.tasks) || g.tasks.length === 0 || g.doneAt !== undefined) return ''
258  return clean(g.name).trim()
259}
260
261/** The first line of a prompt that has text, cut to QUESTION_KEEP; '' for none. */
262export const firstLineOf = (prompt: string): string => cut(prompt.split(/\r?\n/).find(l => l.trim() !== '') ?? '', QUESTION_KEEP)
263
264/** At most this many open TaskCreate ids are kept for the fallback goal. */
265export const TASKS_KEEP = 100
266
267const obj = (v: unknown): Record<string, unknown> => (typeof v === 'object' && v !== null ? (v as Record<string, unknown>) : {})
268const idOf = (v: unknown): string => (typeof v === 'string' ? v : typeof v === 'number' ? String(v) : '')
269
270/**
271 * The fallback goal after a main-loop task-list call that went through. A list
272 * opened by this call takes `prompt` (the first line of the prompt that started
273 * it); a list already open keeps its own, so the goal is the longest-running
274 * list's. Null once no TodoWrite item and no TaskCreate task is left open.
275 */
276export function listGoalOf(prev: HereGoal | null, tool: string, input: Record<string, unknown>, result: unknown, prompt: string): HereGoal | null {
277  let todos = prev?.todos ?? 0
278  let tasks = prev?.tasks ?? []
279  const out = obj(result)
280  switch (tool) {
281    case 'TodoWrite':
282      if (!Array.isArray(input.todos)) return prev
283      todos = input.todos.map(obj).filter(t => typeof t.content === 'string' && t.status !== 'completed').length
284      break
285    case 'TaskCreate': {
286      const id = idOf(obj(out.task).id)
287      if (!id) return prev
288      tasks = [...tasks.filter(t => t !== id), id]
289      break
290    }
291    case 'TaskUpdate': {
292      const id = idOf(input.taskId)
293      // A refusal answers success: false; the task did not change.
294      if (!id || out.success === false) return prev
295      if (input.status === 'completed' || input.status === 'deleted') tasks = tasks.filter(t => t !== id)
296      else if ((input.status === 'pending' || input.status === 'in_progress') && !tasks.includes(id)) tasks = [...tasks, id]
297      break
298    }
299    default:
300      return prev
301  }
302  if (todos === 0 && tasks.length === 0) return null
303  return { text: prev ? prev.text : prompt, todos, tasks: tasks.slice(-TASKS_KEEP) }
304}
305
306// --- the switch and the option
307
308/** The row's switch: /here on|off when set, else on. */
309export const isBandOn = (stored: '' | 'on' | 'off'): boolean => stored !== 'off'
310
311/** The `rewrite` option, held to its listed values: anything but `haiku` is `off`. */
312export const rewriteOf = (option: unknown): 'off' | 'haiku' => (option === 'haiku' ? 'haiku' : 'off')
313
314// --- the Haiku rewrite (option rewrite=haiku)
315
316export const REWRITE_SYSTEM = [
317  'You label where a coding session stands, for a one-line status row.',
318  'Read the assistant reply and the task list, then answer with one JSON object and nothing else:',
319  '{"goal": "...", "now": "...", "you": "..."}',
320  'goal: what the work is for, at most 40 characters, from the task list or the reply.',
321  'now: what was just done or is being done, a verb first ("Editing kit.ts", "Waiting for review"), at most 48 characters.',
322  'you: the one thing the reply asks the person to answer or do, at most 60 characters; "" when it asks nothing.',
323  'Keep file paths, commands and names exactly as written. Use "" for a part you cannot tell.',
324].join('\n')
325
326/** The rewrite's one user message: the reply's tail, the task list and the plain parts. */
327export function rewritePrompt(answer: string, tasks: readonly { title: string; status: string }[], plain: { goal: string; now: string; you: string }): string {
328  const reply = answer.length > REWRITE_REPLY_MAX ? `…${answer.slice(-REWRITE_REPLY_MAX)}` : answer
329  const list = tasks.length ? tasks.map(t => `- [${t.status}] ${t.title}`).join('\n') : '(none)'
330  return [
331    '<reply>',
332    reply,
333    '</reply>',
334    '<tasks>',
335    list,
336    '</tasks>',
337    '<plain>',
338    `goal: ${plain.goal || '(none)'}`,
339    `now: ${plain.now || '(none)'}`,
340    `you: ${plain.you || '(none)'}`,
341    '</plain>',
342  ].join('\n')
343}
344
345/** The rewrite's answer as the three parts, each cut to the row's limits; null when it holds no JSON object. */
346export function parseRewrite(text: string): { goal: string; now: string; you: string } | null {
347  const m = /\{[\s\S]*\}/.exec(text)
348  if (!m) return null
349  try {
350    const o = JSON.parse(m[0]) as Record<string, unknown>
351    if (typeof o !== 'object' || o === null) return null
352    const part = (k: string, max: number) => (typeof o[k] === 'string' ? cut(o[k] as string, max) : '')
353    return { goal: part('goal', GOAL_MAX), now: part('now', NOW_MAX), you: part('you', YOU_MAX) }
354  } catch {
355    return null
356  }
357}
358
359// --- the three parts, as the row and /here show them
360
361export type Parts = { goal: string; now: string; you: string[]; isRewritten: boolean }
362
363/** The parts shown: the rewrite's where it said something, else the plain ones. */
364export function partsOf(goal: string, now: string, you: readonly string[], rw: { goal: string; now: string; you: string } | null): Parts {
365  if (!rw) return { goal, now, you: [...you], isRewritten: false }
366  return {
367    goal: rw.goal || goal,
368    now: rw.now || now,
369    you: rw.you ? [rw.you] : [...you],
370    isRewritten: Boolean(rw.goal || rw.now || rw.you),
371  }
372}
373
374/** What /here prints: the three parts, one line each, every question under `you`. */
375export function summaryOf(p: Parts, isOn: boolean): string {
376  const lines = [`goal  ${p.goal || '-'}`, `now   ${p.now || '-'}`]
377  if (p.you.length === 0) lines.push('you   -')
378  else p.you.forEach((q, i) => lines.push(`${i === 0 ? 'you   ' : '      '}${q}`))
379  if (p.isRewritten) lines.push('(rewritten by Haiku from the last reply)')
380  if (!isOn) lines.push('(the HERE row is off: /here on)')
381  return lines.join('\n')
382}
383
384// Stack order of the zehui-mods band rows (plan 2026-10-20-boards-codemap-recap,
385// section 0): SYS 10, PLAN 20, HANDOFF 30, CACHE 40, AGENTS 50, GOAL 60, HERE 70,
386// NEXT 80. Copied unchanged from the canonical
387// ~/.claude/skills/building-claude-code-mods/references/stack-row.tsx; change it
388// there first, then in every band mod.
389
390/** The rows each zehui-mods band draws, by stack order. */
391export const STACK = { sys: 10, plan: 20, handoff: 30, cache: 40, agents: 50, goal: 60, here: 70, next: 80 } as const
392
393/** A row's key: `zm-<order>-<name>`. */
394export const stackKey = (name: keyof typeof STACK): string => `zm-${STACK[name]}-${name}`
395
396type Loose = { type?: unknown; props?: Record<string, unknown> | null; part?: unknown; children?: unknown }
397
398/** The order of a zehui-mods row (`zm-<order>-<name>`), or null for any other node. */
399export const stackOrderOf = (node: unknown): number | null => {
400  const key = node && typeof node === 'object' ? (node as Loose).props?.key : undefined
401  const m = typeof key === 'string' ? /^zm-(\d+)-/.exec(key) : null
402  return m ? Number(m[1]) : null
403}
404
405/**
406 * The column a band hook beneath built: a Box whose only prop is
407 * `flexDirection: 'column'` and whose direct children hold a zm row. A Box with
408 * other props (a border, a padding) is another mod's own drawing, kept whole.
409 */
410const isStack = (node: Loose): node is Loose & { children: unknown[] } =>
411  node.type === 'Box' &&
412  node.props?.flexDirection === 'column' &&
413  Object.keys(node.props).every(k => k === 'flexDirection') &&
414  Array.isArray(node.children) &&
415  node.children.some(c => stackOrderOf(c) !== null)
416
417/**
418 * `row` put in its slot among the zm rows of `below` (what `next(e)` drew):
419 * zm rows sorted by their number, then every other child in its original order.
420 * Nothing beneath (null, a failed next, the engine placeholder): the row alone.
421 */
422export function stackRow(row: RenderElement, below: unknown): RenderElement {
423  const tree = below && typeof below === 'object' && (below as Loose).type !== 'engine' ? (below as Loose) : null
424  if (!tree) return row
425  let items: unknown[] = [tree]
426  if (isStack(tree)) {
427    items = [...tree.children]
428    // The column's mark (whose drawing started there) moves to the one row
429    // that carries none, its own; the engine lets a mark move, never multiply.
430    const bare = items.filter(c => c && typeof c === 'object' && !(c as Loose).part)
431    if (tree.part && bare.length === 1) items = items.map(c => (c === bare[0] ? { ...(c as object), part: tree.part } : c))
432  }
433  const rows = items.filter(c => stackOrderOf(c) !== null)
434  const others = items.filter(c => stackOrderOf(c) === null)
435  // A stable sort: two rows of one number keep their order, this row after.
436  const sorted = [...rows, row].sort((a, b) => (stackOrderOf(a) ?? 0) - (stackOrderOf(b) ?? 0))
437  return { type: 'Box', props: { flexDirection: 'column' }, children: [...sorted, ...others] as RenderNode[] }
438}
439
hooks/live.ts 12 lines
1// The live turn, kept in the module and not in $.state: the spinner's word is
2// seen only while ui.render draws the Spinner, and drawing is pure (the host
3// denies a state write made during a render, probe 2026-10-20), so the word is
4// kept here and the HERE row reads it. Reset at session.start; a reload starts
5// it afresh with the module.
6export const live = {
7  /** True from a main-loop turn's start to its end. */
8  isTurnRunning: false,
9  /** The terminal spinner's word as last drawn; '' after a main-loop turn ends. */
10  spinnerWord: '',
11}
12
hooks/row.tsx 125 lines
1// The ◉ HERE row above the prompt: `goal <name> · now <doing> · you: "<question>"`.
2// The goal is goal-meter's goal name while its list is open, else the first
3// line of the prompt that started the main loop's open task list; now is the last
4// main-loop tool call's verb and path, or the spinner word (live.ts) before the
5// turn's first tool call; you is the first question line of the last reply, cut to
6// 60 columns, `+n` when it asked more. The row shows whenever any of the
7// three has a value. Its row sits in the zehui-mods stack by its order
8// (stackRow); other mods' bands follow.
9import { atom, read } from 'claude-code'
10import type { EngineInterface, PluginOptions, RenderElement, RenderInput } from 'claude-code'
11
12import type { HereNow } from '../types'
13
14import {
15  CHIP,
16  CHIP_BG,
17  CHIP_FG,
18  GOAL_MAX,
19  GOAL_SHORT,
20  NOW_MAX,
21  NOW_SHORT,
22  YOU_MAX,
23  cellsOf,
24  clean,
25  cut,
26  goalOf,
27  isBandOn,
28  partsOf,
29  rewriteOf,
30  stackKey,
31  stackRow,
32} from './kit'
33import { live } from './live'
34
35const now = atom({ plugin: 'here', key: 'now' } as const, null)
36const you = atom({ plugin: 'here', key: 'you' } as const, [])
37const band = atom({ plugin: 'here', key: 'band' } as const, '')
38const rewrite = atom({ plugin: 'here', key: 'rewrite' } as const, null)
39const goal = atom({ plugin: 'here', key: 'goal' } as const, null)
40const GOAL_METER = { plugin: 'goal-meter', key: 'goal' } as const
41
42/** One run of text in the row: its words and how they are drawn. */
43type Seg = { text: string; color?: string; bg?: string; isBold?: boolean; isDim?: boolean }
44
45const widthOf = (segs: readonly Seg[]): number => segs.reduce((w, s) => w + cellsOf(s.text), 0)
46
47/**
48 * goal-meter's goal name and tasks while its list is open; else the fallback
49 * goal (the first line of the prompt that started the open task list) with no
50 * tasks; else ''.
51 */
52export async function peerGoal($: EngineInterface): Promise<{ name: string; tasks: { title: string; status: string }[] }> {
53  try {
54    const g = (await $.state.get(GOAL_METER)).value
55    const name = goalOf(g)
56    if (name && g) return { name, tasks: g.tasks.filter(t => t && typeof t.title === 'string').map(t => ({ title: clean(t.title), status: String(t.status) })) }
57  } catch {
58    // goal-meter is absent: the fallback.
59  }
60  try {
61    return { name: (await read($, goal))?.text ?? '', tasks: [] }
62  } catch {
63    return { name: '', tasks: [] }
64  }
65}
66
67/** `now` as shown: the last tool call, or the spinner word while a turn has made none. */
68export const nowText = (n: HereNow | null): string => n?.text ?? (live.isTurnRunning ? live.spinnerWord : '')
69
70export async function drawRow($: EngineInterface, e: RenderInput<'AbovePrompt'>, pass: () => Promise<RenderElement>, options: PluginOptions) {
71  if (e.surface !== 'terminal' || e.props.hasSurvey) return pass()
72  if (!isBandOn(await read($, band))) return pass()
73  const n = await read($, now)
74  const rw = rewriteOf(options.rewrite) === 'haiku' ? await read($, rewrite) : null
75  const p = partsOf((await peerGoal($)).name, nowText(n), await read($, you), rw)
76  if (!p.goal && !p.now && p.you.length === 0) return pass()
77
78  // Other mods' bands beneath this row; stackRow puts this row in its slot
79  // among the zehui-mods rows and never nests the engine's own placeholder.
80  const below = await pass().catch(() => null)
81
82  const isSpinner = n === null && p.now !== '' && p.now === nowText(n)
83  const build = (goalMax: number, nowMax: number): Seg[] => {
84    const parts: Seg[][] = []
85    if (p.goal) parts.push([{ text: 'goal ', isDim: true }, { text: cut(p.goal, goalMax), isBold: true }])
86    if (p.now) parts.push([{ text: 'now ', isDim: true }, { text: `${cut(p.now, nowMax)}${isSpinner ? '…' : ''}` }])
87    if (p.you.length > 0) {
88      parts.push([
89        { text: 'you: ', isDim: true },
90        { text: `"${cut(p.you[0] ?? '', YOU_MAX)}"`, color: CHIP_BG, isBold: true },
91        ...(p.you.length > 1 ? [{ text: ` +${p.you.length - 1}`, isDim: true }] : []),
92      ])
93    }
94    const body = parts.flatMap((segs, i) => (i === 0 ? segs : [{ text: ' · ', isDim: true }, ...segs]))
95    return [{ text: CHIP, color: CHIP_FG, bg: CHIP_BG, isBold: true }, { text: ' ' }, ...body]
96  }
97  const cols = e.props.bodyColumns > 0 ? e.props.bodyColumns : 80
98  // The question is what the person acts on: on a narrow band the goal gives first, then now.
99  let segs = build(GOAL_MAX, NOW_MAX)
100  if (widthOf(segs) > cols) segs = build(GOAL_SHORT, NOW_MAX)
101  if (widthOf(segs) > cols) segs = build(GOAL_SHORT, NOW_SHORT)
102
103  const { Box, Text } = $.ui.resolve(e)
104  const row = (
105    <Box key={stackKey('here')} flexDirection="row">
106      <Box key="here-line" flexDirection="row">
107        <Text wrap="truncate-end">
108          {segs.map((s, i) => (
109            <Text
110              key={`seg-${i}`}
111              {...(s.color ? { color: s.color } : {})}
112              {...(s.bg ? { backgroundColor: s.bg } : {})}
113              {...(s.isBold ? { bold: true } : {})}
114              {...(s.isDim ? { dimColor: true } : {})}
115            >
116              {s.text}
117            </Text>
118          ))}
119        </Text>
120      </Box>
121    </Box>
122  )
123  return stackRow(row, below)
124}
125
types/index.d.ts 55 lines
1// The here mod's state contract. The one value of goal-meter's it reads is
2// typed in peer.d.ts beside this file, kept out of this contract so the
3// contract names only here's own state.
4
5/**
6 * What Claude is doing now: the main loop's last tool call. (The spinner word,
7 * shown before a turn's first tool call, is not state: it is seen only while
8 * the Spinner is drawn, when no state may be written; see hooks/live.ts.)
9 */
10export type HereNow = {
11  /** `Editing agents/hooks/kit.ts`, `Running pytest`. */
12  text: string
13  /** When it was recorded, `$.clock.now()` milliseconds. */
14  at: number
15}
16
17/** The three parts as one Haiku call rewrote them (option rewrite=haiku); empty strings fall back to the plain parts. */
18export type HereRewrite = {
19  goal: string
20  now: string
21  you: string
22  /** The main-loop turn whose answer it was made from. */
23  turnId: string
24}
25
26/**
27 * The goal when goal-meter has none: the first line of the prompt that started
28 * the main loop's open task list, kept while any of its items is open.
29 */
30export type HereGoal = {
31  /** That prompt's first line, cut to 200 characters; '' when no prompt was seen. */
32  text: string
33  /** TodoWrite items not completed, as the last TodoWrite listed them. */
34  todos: number
35  /** Ids of TaskCreate tasks not yet completed or deleted. */
36  tasks: string[]
37}
38
39declare module 'claude-code' {
40  interface PluginState {
41    here: {
42      /** The last tool call of the main loop; null before any, and from a main-loop turn's start to its first tool call. */
43      now: HereNow | null
44      /** The question lines of the last main-loop reply, each cut to 200 characters; [] once the next prompt is sent. */
45      you: string[]
46      /** /here on|off as kept in the store; '' while the default (on) applies. */
47      band: '' | 'on' | 'off'
48      /** The Haiku rewrite of the last answer; null without one, and once the next prompt is sent. */
49      rewrite: HereRewrite | null
50      /** The fallback goal (goal-meter has none): null while no main-loop task list is open. */
51      goal: HereGoal | null
52    }
53  }
54}
55