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…

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-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.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.?, 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).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.| Command | What it does |
|---|---|
/here | Prints the three parts, one line each, every kept question under you. |
/here on / off | Shows or hides the row (kept in the store across sessions). |
The row has no buttons, so nothing in it needs the band focused.
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.
claude plugin install here@zehui-mods --scope user
rewrite=haiku.hooks/live.ts) and the Spinner hook asks for one redraw when it changes.See PROVENANCE.md for where it came from.
hooks/register.tsx 245 lines1// 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}
245hooks/kit.ts 439 lines1// 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}
439hooks/live.ts 12 lines1// 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}
12hooks/row.tsx 125 lines1// 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}
125types/index.d.ts 55 lines1// 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