crewforth 3.5.0-rc.1 → 3.5.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/VERSION +1 -1
  4. package/kit/CLAUDE.md +1 -1
  5. package/kit/eval/lib/statusline.sh +22 -5
  6. package/kit/eval/lib/todo.awk +167 -0
  7. package/kit/eval/lib/todo.sh +120 -0
  8. package/kit/eval/smoke-test.sh +60 -2
  9. package/kit/hooks/prompt-approval.sh +5 -5
  10. package/kit/skills/crew-plan/SKILL.md +2 -1
  11. package/kit/skills/crew-todo/SKILL.md +31 -0
  12. package/kit/skills/spec-planning/SKILL.md +9 -2
  13. package/kit/studio/web/agent-spend.js +32 -0
  14. package/kit/studio/web/app.js +245 -236
  15. package/kit/studio/web/approvals.js +14 -13
  16. package/kit/studio/web/ask.js +10 -9
  17. package/kit/studio/web/canvas.js +56 -54
  18. package/kit/studio/web/chat.js +41 -41
  19. package/kit/studio/web/convo.js +21 -18
  20. package/kit/studio/web/dock.js +20 -19
  21. package/kit/studio/web/graph-plan.js +62 -22
  22. package/kit/studio/web/i18n-tr.js +760 -0
  23. package/kit/studio/web/i18n.js +79 -0
  24. package/kit/studio/web/index.html +1 -0
  25. package/kit/studio/web/inspect.js +27 -24
  26. package/kit/studio/web/list-plan.js +16 -15
  27. package/kit/studio/web/list.js +6 -5
  28. package/kit/studio/web/liveness.js +11 -9
  29. package/kit/studio/web/map-plan.js +33 -29
  30. package/kit/studio/web/map.js +74 -73
  31. package/kit/studio/web/models-plan.js +47 -42
  32. package/kit/studio/web/models.js +21 -20
  33. package/kit/studio/web/names.js +13 -10
  34. package/kit/studio/web/nav.js +34 -33
  35. package/kit/studio/web/newsession.js +25 -24
  36. package/kit/studio/web/style.css +2 -0
  37. package/kit/studio/web/theme.js +5 -2
  38. package/kit/studio/web/timeline-plan.js +19 -18
  39. package/kit/studio/web/timeline.js +23 -22
  40. package/kit/studio/web/usage.js +36 -32
  41. package/package.json +1 -1
@@ -16,6 +16,7 @@ import { renderMarkdown } from './md.js';
16
16
  import {
17
17
  toolBlock, resultsIn, isDelegation, summaryOf, outputOf, delegationCard, reminderOf, headerOf, refusals,
18
18
  } from './convo.js';
19
+ import { t, tParts } from './i18n.js';
19
20
 
20
21
  const el = (tag, cls, text) => {
21
22
  const n = document.createElement(tag);
@@ -148,7 +149,7 @@ class Pane {
148
149
  this.remindEl = el('div', 'chat-remind');
149
150
  this.remindEl.hidden = true;
150
151
  this.remindText = el('span', 'chat-remind-text');
151
- const review = el('button', 'chat-review', 'Review');
152
+ const review = el('button', 'chat-review', t('Review'));
152
153
  review.type = 'button';
153
154
  review.addEventListener('click', () => this.hooks.onReview?.(this));
154
155
  this.remindEl.append(el('span', 'dot'), this.remindText, review);
@@ -157,12 +158,12 @@ class Pane {
157
158
  this.formEl = el('form', 'chat-form');
158
159
  this.inputEl = el('textarea', 'chat-input');
159
160
  this.inputEl.rows = 3;
160
- this.inputEl.placeholder = 'Message this session';
161
- this.inputEl.setAttribute('aria-label', 'Message this session');
161
+ this.inputEl.placeholder = t('Message this session');
162
+ this.inputEl.setAttribute('aria-label', t('Message this session'));
162
163
  const formFoot = el('div', 'chat-form-foot');
163
- const send = el('button', 'btn sm primary chat-send', 'Send');
164
+ const send = el('button', 'btn sm primary chat-send', t('Send'));
164
165
  send.type = 'submit';
165
- formFoot.append(el('span', 'sub', 'Enter to send · Shift+Enter for a new line'), el('span', 'row-fill'), send);
166
+ formFoot.append(el('span', 'sub', t('Enter to send · Shift+Enter for a new line')), el('span', 'row-fill'), send);
166
167
  this.formEl.append(this.inputEl, formFoot);
167
168
 
168
169
  this.roEl = el('div', 'chat-ro');
@@ -205,15 +206,15 @@ class Pane {
205
206
  conv = await fetch(this.api(`/api/session/${encodeURIComponent(from)}/conversation`), { cache: 'no-store' })
206
207
  .then((r) => r.json());
207
208
  } catch (e) {
208
- this.note(`History not read: ${e.message}`, 'bad');
209
+ this.note(t('History not read: {reason}', { reason: e.message }), 'bad');
209
210
  return;
210
211
  }
211
- if (conv?.transcriptsHidden) { this.note('Transcripts are hidden on this link', 'muted'); return; }
212
- if (conv?.measured === false) { this.note(`History not measured — ${conv.reason}`, 'bad'); return; }
212
+ if (conv?.transcriptsHidden) { this.note(t('Transcripts are hidden on this link'), 'muted'); return; }
213
+ if (conv?.measured === false) { this.note(t('History not measured — {reason}', { reason: conv.reason }), 'bad'); return; }
213
214
 
214
215
  const frag = document.createDocumentFragment();
215
216
  if (conv.truncated) {
216
- frag.append(el('div', 'chat-note', `${conv.total - conv.messages.length} earlier message(s) not shown`));
217
+ frag.append(el('div', 'chat-note', t('{n} earlier message(s) not shown', { n: conv.total - conv.messages.length })));
217
218
  }
218
219
  for (const m of conv.messages ?? []) frag.append(this.renderMessage(m, { quick: false }));
219
220
  if (!this.readOnly) {
@@ -223,8 +224,8 @@ class Pane {
223
224
  // is the one thing it is not; a real continuation that calls itself a
224
225
  // fork undersells it.
225
226
  frag.append(el('div', 'chat-seam', this.session?.forked
226
- ? `— forked from ${from.slice(0, 8)} · a separate session; the original does not see this —`
227
- : `— continuing ${from.slice(0, 8)} · same session, same transcript —`));
227
+ ? t('— forked from {id} · a separate session; the original does not see this —', { id: from.slice(0, 8) })
228
+ : t('— continuing {id} · same session, same transcript —', { id: from.slice(0, 8) })));
228
229
  }
229
230
  this.logEl.prepend(frag);
230
231
  this.logEl.scrollTop = this.logEl.scrollHeight;
@@ -328,7 +329,7 @@ class Pane {
328
329
  return wrap;
329
330
  }
330
331
 
331
- wrap.append(el('div', 'msg-role', 'Session'));
332
+ wrap.append(el('div', 'msg-role', t('Session')));
332
333
  const body = el('div', 'msg-body');
333
334
  for (const b of msg.blocks) {
334
335
  if (b.kind === 'text') {
@@ -355,7 +356,7 @@ class Pane {
355
356
  b.addEventListener('click', () => { row.remove(); this.sendText(o); });
356
357
  row.append(b);
357
358
  }
358
- row.append(el('span', 'sub quick-hint', 'Clicking an option sends it as your reply.'));
359
+ row.append(el('span', 'sub quick-hint', t('Clicking an option sends it as your reply.')));
359
360
  wrap.append(row);
360
361
  }
361
362
  }
@@ -377,7 +378,7 @@ class Pane {
377
378
  row.addEventListener('click', () => {
378
379
  const text = outputOf(block);
379
380
  // A call that has not come back has nothing to open, and says which it is rather than opening an empty box.
380
- out.textContent = text ?? (this.readOnly ? 'Output not read from the transcript.' : 'Not returned yet.');
381
+ out.textContent = text ?? (this.readOnly ? t('Output not read from the transcript.') : t('Not returned yet.'));
381
382
  out.hidden = !out.hidden;
382
383
  row.setAttribute('aria-expanded', String(!out.hidden));
383
384
  });
@@ -463,7 +464,7 @@ class Pane {
463
464
  }
464
465
  node.replaceChildren(...parts);
465
466
  node.disabled = !card.agentId;
466
- node.title = card.agentId ? 'Show this agent on the graph' : 'This agent is not on the graph being shown';
467
+ node.title = card.agentId ? t('Show this agent on the graph') : t('This agent is not on the graph being shown');
467
468
  }
468
469
 
469
470
  /** The graph moved: the cards say what their agents are doing now. */
@@ -487,7 +488,7 @@ class Pane {
487
488
  if (!this.streaming) { node?.remove(); return; }
488
489
  if (!node) {
489
490
  node = el('div', 'msg msg-assistant msg-streaming');
490
- node.append(el('div', 'msg-role', 'Session'));
491
+ node.append(el('div', 'msg-role', t('Session')));
491
492
  node.append(el('div', 'msg-body'));
492
493
  this.logEl.append(node);
493
494
  }
@@ -496,7 +497,7 @@ class Pane {
496
497
  body.textContent = this.streaming.text;
497
498
  body.classList.add('msg-text');
498
499
  } else {
499
- body.replaceChildren(el('span', 'thinking', 'thinking…'));
500
+ body.replaceChildren(el('span', 'thinking', t('thinking…')));
500
501
  }
501
502
  this.scroll();
502
503
  }
@@ -512,24 +513,24 @@ class Pane {
512
513
  const parts = [badge];
513
514
  for (const p of h.parts) {
514
515
  // The mode is a control: a click offers the modes a session can be in. Everything else in the strip is said.
515
- if (h.mode && p === `mode: ${h.mode}` && this.hooks.onMode && s?.state !== 'exited' && s?.state !== 'failed') {
516
+ if (h.mode && p === t('mode: {mode}', { mode: h.mode }) && this.hooks.onMode && s?.state !== 'exited' && s?.state !== 'failed') {
516
517
  const b = el('button', 'sub pane-mode', p);
517
518
  b.type = 'button';
518
- b.title = 'Change the mode this session is in';
519
+ b.title = t('Change the mode this session is in');
519
520
  b.setAttribute('aria-haspopup', 'menu');
520
521
  b.addEventListener('click', (e) => { e.stopPropagation(); this.hooks.onMode(this, b, h.mode); });
521
522
  parts.push(b);
522
523
  } else parts.push(el('span', 'sub', p));
523
524
  }
524
525
  if (h.ungated) {
525
- const warn = el('span', 'pill pane-ungated', 'No approval gate');
526
- warn.title = 'This session was started without the approval gate: its tool calls do not wait for you.';
526
+ const warn = el('span', 'pill pane-ungated', t('No approval gate'));
527
+ warn.title = t('This session was started without the approval gate: its tool calls do not wait for you.');
527
528
  parts.push(warn);
528
529
  }
529
530
  parts.push(el('span', 'row-fill'));
530
531
  if (!this.readOnly && s?.costUsd) {
531
532
  const cost = el('span', 'sub pane-cost', `$${s.costUsd.toFixed(4)}`);
532
- cost.title = 'What this session has cost, as the CLI reported it';
533
+ cost.title = t('What this session has cost, as the CLI reported it');
533
534
  parts.push(cost);
534
535
  }
535
536
  // Stopping ends the process. It is offered for as long as there is one.
@@ -537,7 +538,7 @@ class Pane {
537
538
  const stop = el('button', 'btn sm chat-stop');
538
539
  stop.type = 'button';
539
540
  stop.innerHTML = '<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><rect x="4" y="4" width="8" height="8" rx="1.5"/></svg>';
540
- stop.append(el('span', null, 'Stop'));
541
+ stop.append(el('span', null, t('Stop')));
541
542
  stop.addEventListener('click', () => this.stop());
542
543
  parts.push(stop);
543
544
  }
@@ -553,36 +554,35 @@ class Pane {
553
554
  */
554
555
  paintReadOnly() {
555
556
  const lead = el('p', 'chat-ro-lead');
556
- lead.append(el('strong', null, 'This session was not started here.'),
557
- ' Studio can read it but cannot write to it or answer its approvals.');
557
+ lead.append(el('strong', null, t('This session was not started here.')),
558
+ ` ${t('Studio can read it but cannot write to it or answer its approvals.')}`);
558
559
 
559
- const cont = el('button', 'btn sm primary', 'Continue here');
560
+ const cont = el('button', 'btn sm primary', t('Continue here'));
560
561
  cont.type = 'button';
561
562
  cont.addEventListener('click', () => this.hooks.onContinue?.(this, cont));
562
563
  const contRow = el('div', 'chat-ro-row');
563
564
  // Said before the press, from the server's own check (the one Continue here acts on): the same session, or a copy.
564
565
  // Until the server answers, and if it cannot: the sentence true of both outcomes.
565
- const FALLBACK = 'Continues it in Studio. If it is still open somewhere else, Studio starts a copy instead: '
566
- + 'the original does not see this, and messages here never reach your terminal.';
566
+ const FALLBACK = t('Continues it in Studio. If it is still open somewhere else, Studio starts a copy instead: the original does not see this, and messages here never reach your terminal.');
567
567
  const contText = el('span', 'sub', FALLBACK);
568
568
  contRow.append(cont, contText);
569
569
 
570
- const term = el('button', 'btn sm', 'Open in terminal');
570
+ const term = el('button', 'btn sm', t('Open in terminal'));
571
571
  term.type = 'button';
572
572
  term.addEventListener('click', (e) => { e.stopPropagation(); this.hooks.onTerminal?.(this, term); });
573
573
  const line = el('code', 'chat-ro-cmd');
574
574
  const termText = el('span', 'sub');
575
- termText.append('Shows the command first, then opens a terminal: ', line);
575
+ termText.append(...tParts('Shows the command first, then opens a terminal: {command}', { command: line }));
576
576
  const termRow = el('div', 'chat-ro-row');
577
577
  termRow.append(term, termText);
578
578
 
579
579
  // Back to the desktop app: the command, to copy and run where the app is.
580
580
  const deskLine = el('code', 'chat-ro-cmd');
581
- const deskCopy = el('button', 'btn sm', 'Copy');
581
+ const deskCopy = el('button', 'btn sm', t('Copy'));
582
582
  deskCopy.type = 'button';
583
583
  deskCopy.addEventListener('click', () => { if (deskLine.dataset.cmd) navigator.clipboard?.writeText(deskLine.dataset.cmd).then(() => { deskCopy.textContent = 'Copied'; }, () => {}); });
584
584
  const deskText = el('span', 'sub');
585
- deskText.append('Open it in the desktop app again: ', deskLine);
585
+ deskText.append(...tParts('Open it in the desktop app again: {command}', { command: deskLine }));
586
586
  const deskRow = el('div', 'chat-ro-row');
587
587
  deskRow.append(deskCopy, deskText);
588
588
 
@@ -592,18 +592,18 @@ class Pane {
592
592
  fetch(this.api(`/api/session/${encodeURIComponent(this.id)}/terminal`), { cache: 'no-store' })
593
593
  .then((r) => r.json())
594
594
  .then((r) => {
595
- line.textContent = r?.plan?.line ?? 'not measured — no working directory or session id recorded';
595
+ line.textContent = r?.plan?.line ?? t('not measured — no working directory or session id recorded');
596
596
  // On Windows the command is a PowerShell line (no one line works in cmd and PowerShell 5.1 both): say so.
597
- deskLine.textContent = r?.desktop?.line ?? 'not measured — no working directory or session id recorded';
597
+ deskLine.textContent = r?.desktop?.line ?? t('not measured — no working directory or session id recorded');
598
598
  if (r?.desktop?.line) deskLine.dataset.cmd = r.desktop.line; else deskCopy.disabled = true;
599
- if (r?.desktop?.shell === 'PowerShell') deskText.append(' (in PowerShell)');
599
+ if (r?.desktop?.shell === 'PowerShell') deskText.append(` ${t('(in PowerShell)')}`);
600
600
  contText.textContent = r?.continueAs === 'same'
601
- ? 'Continues this same session in Studio: its id and history stay, and the desktop app or a terminal that opens it later sees what you add here. Nothing else has it open now.'
601
+ ? t('Continues this same session in Studio: its id and history stay, and the desktop app or a terminal that opens it later sees what you add here. Nothing else has it open now.')
602
602
  : r?.continueAs === 'copy'
603
- ? 'It is open somewhere else right now, so Studio starts a copy: the original does not see this, and messages here never reach your terminal or the desktop app.'
603
+ ? t('It is open somewhere else right now, so Studio starts a copy: the original does not see this, and messages here never reach your terminal or the desktop app.')
604
604
  : FALLBACK;
605
605
  })
606
- .catch((e) => { line.textContent = `not read — ${e.message}`; deskLine.textContent = `not read — ${e.message}`; deskCopy.disabled = true; });
606
+ .catch((e) => { line.textContent = t('not read — {reason}', { reason: e.message }); deskLine.textContent = t('not read — {reason}', { reason: e.message }); deskCopy.disabled = true; });
607
607
  }
608
608
 
609
609
  paintState() {
@@ -654,7 +654,7 @@ class Pane {
654
654
  body: JSON.stringify({ text }),
655
655
  }).then((r) => r.json()).catch((e) => ({ ok: false, reason: e.message }));
656
656
 
657
- if (!res.ok) { this.clearPending(); this.note(`Not sent: ${res.reason}`, 'bad'); }
657
+ if (!res.ok) { this.clearPending(); this.note(t('Not sent: {reason}', { reason: res.reason }), 'bad'); }
658
658
  else { this.session = res.session; this.paintState(); this.onChange(this); }
659
659
  return res;
660
660
  }
@@ -811,11 +811,11 @@ export class Chat {
811
811
  tab.append(el('span', 'tab-name', String(name).slice(0, 28)));
812
812
  if (p.permissions?.length) tab.append(el('span', 'tab-badge warn', String(p.permissions.length)));
813
813
  else if (p.unread) tab.append(el('span', 'tab-badge', String(p.unread)));
814
- tab.title = `${name}\n${id}\n${p.readOnly ? 'read only' : `${p.session?.state ?? ''} · ${p.session?.permissionMode ?? ''}`}`;
814
+ tab.title = `${name}\n${id}\n${p.readOnly ? t('read only') : `${p.session?.state ?? ''} · ${p.session?.permissionMode ?? ''}`}`;
815
815
  tab.addEventListener('click', () => this.activate(id));
816
816
 
817
817
  const x = el('span', 'tab-x', '×');
818
- x.title = 'Close this tab — the session keeps running';
818
+ x.title = t('Close this tab — the session keeps running');
819
819
  x.addEventListener('click', (e) => { e.stopPropagation(); this.close(id); });
820
820
  tab.append(x);
821
821
  frag.append(tab);
@@ -4,6 +4,7 @@
4
4
  // watches) and the live stream of a session started here. Both go through these functions, so a tool row reads
5
5
  // the same whichever way it arrived.
6
6
  import { roleName, modelOf } from './names.js';
7
+ import { t, numLocale } from './i18n.js';
7
8
 
8
9
  // How much of a tool's output the page keeps for a live result: the same tail the server keeps for history.
9
10
  const RESULT_TAIL = 4096;
@@ -60,7 +61,7 @@ export function summaryOf(block) {
60
61
  if (!r) return { state: 'pending', text: null };
61
62
  const lines = r.text.split('\n').map((l) => l.trim()).filter(Boolean);
62
63
  const last = lines.length ? lines[lines.length - 1] : null;
63
- const text = last == null ? 'no output' : (last.length > SUMMARY_MAX ? `${last.slice(0, SUMMARY_MAX - 1)}…` : last);
64
+ const text = last == null ? t('no output') : (last.length > SUMMARY_MAX ? `${last.slice(0, SUMMARY_MAX - 1)}…` : last);
64
65
  return { state: r.error ? 'error' : 'ok', text };
65
66
  }
66
67
 
@@ -68,8 +69,8 @@ export function summaryOf(block) {
68
69
  export function outputOf(block) {
69
70
  const r = block?.result;
70
71
  if (!r) return null;
71
- if (!r.text) return '(no output)';
72
- return r.truncated ? `… ${(r.length - r.text.length).toLocaleString('en-US')} earlier characters not shown\n${r.text}` : r.text;
72
+ if (!r.text) return t('(no output)');
73
+ return r.truncated ? `${t('… {n} earlier characters not shown', { n: (r.length - r.text.length).toLocaleString(numLocale()) })}\n${r.text}` : r.text;
73
74
  }
74
75
 
75
76
  /**
@@ -112,11 +113,11 @@ export function refusals(denials, wasAllowed, resultOf = () => null) {
112
113
  // Who refused is in the result's own words. Another PreToolUse hook that blocked the call is reported by
113
114
  // the harness as a hook error; anything else is the harness's own check. With no result on screen the line
114
115
  // says only what is known: it was allowed here and did not run.
115
- const by = reason == null ? 'it did not run'
116
- : /^PreToolUse:\S* hook error/.test(reason) ? 'another gate blocked it' : 'Claude Code refused it';
116
+ const by = reason == null ? t('it did not run')
117
+ : /^PreToolUse:\S* hook error/.test(reason) ? t('another gate blocked it') : t('Claude Code refused it');
117
118
  out.push({
118
119
  id: d.tool_use_id,
119
- text: `Allowed here, but ${by}: ${d.tool_name ?? 'tool'}${what ? ` · ${String(what).slice(0, 120)}` : ''}`
120
+ text: `${t('Allowed here, but {by}: {tool}', { by, tool: d.tool_name ?? t('tool') })}${what ? ` · ${String(what).slice(0, 120)}` : ''}`
120
121
  + (reason ? ` — ${reason.slice(0, 200)}` : ''),
121
122
  });
122
123
  }
@@ -127,31 +128,33 @@ export function refusals(denials, wasAllowed, resultOf = () => null) {
127
128
  export function reminderOf(waiting) {
128
129
  const list = waiting ?? [];
129
130
  if (!list.length) return null;
130
- const who = list[0].agentType ? roleName(list[0].agentType) : 'This session';
131
+ const who = list[0].agentType ? roleName(list[0].agentType) : t('This session');
131
132
  const more = list.length - 1;
132
- return `${who} is waiting for approval${more > 0 ? `, and ${more} more ${more === 1 ? 'request is' : 'requests are'}` : ''}`;
133
+ if (more <= 0) return t('{who} is waiting for approval', { who });
134
+ return more === 1 ? t('{who} is waiting for approval, and 1 more request is', { who })
135
+ : t('{who} is waiting for approval, and {n} more requests are', { who, n: more });
133
136
  }
134
137
 
135
138
  // The modes a session started here can be put in: the three it can be started in, and no other.
136
139
  export const SESSION_MODES = [
137
- { mode: 'plan', label: 'plan', title: 'Explore and plan; nothing is edited until a plan is approved.' },
138
- { mode: 'acceptEdits', label: 'acceptEdits', title: 'File edits in the project are accepted; every call is still shown in the dock first.' },
139
- { mode: 'default', label: 'default', title: 'Each call is asked about in the dock.' },
140
+ { mode: 'plan', label: 'plan', title: t('Explore and plan; nothing is edited until a plan is approved.') },
141
+ { mode: 'acceptEdits', label: 'acceptEdits', title: t('File edits in the project are accepted; every call is still shown in the dock first.') },
142
+ { mode: 'default', label: 'default', title: t('Each call is asked about in the dock.') },
140
143
  ];
141
144
 
142
145
  /** The strip above a conversation: what kind of session this is, in the order the design has it. */
143
146
  export function headerOf(session, { readOnly = false, contextTokens = null } = {}) {
144
- if (readOnly) return { badge: 'Read only', tone: 'none', parts: [] };
147
+ if (readOnly) return { badge: t('Read only'), tone: 'none', parts: [] };
145
148
  const parts = [];
146
- if (session?.permissionMode) parts.push(`mode: ${session.permissionMode}`);
149
+ if (session?.permissionMode) parts.push(t('mode: {mode}', { mode: session.permissionMode }));
147
150
  const facts = [];
148
- if (typeof session?.turns === 'number') facts.push(`${session.turns} ${session.turns === 1 ? 'turn' : 'turns'}`);
149
- if (contextTokens != null) facts.push(`${fmtK(contextTokens)} ctx`);
151
+ if (typeof session?.turns === 'number') facts.push(session.turns === 1 ? t('{n} turn', { n: 1 }) : t('{n} turns', { n: session.turns }));
152
+ if (contextTokens != null) facts.push(t('{n} ctx', { n: fmtK(contextTokens) }));
150
153
  if (facts.length) parts.push(facts.join(' · '));
151
154
  const changed = changesLine(session?.changes);
152
155
  if (changed) parts.push(changed);
153
156
  return {
154
- badge: 'Started here',
157
+ badge: t('Started here'),
155
158
  tone: 'good',
156
159
  parts,
157
160
  // The mode on its own as well: in the strip it is a control, and the rest of `parts` is not.
@@ -168,8 +171,8 @@ export function headerOf(session, { readOnly = false, contextTokens = null } = {
168
171
  export function changesLine(c) {
169
172
  if (!c?.measured || (!c.files && !c.untracked)) return null;
170
173
  const bits = [];
171
- if (c.files) bits.push(`${c.files} ${c.files === 1 ? 'file' : 'files'} changed (+${c.added} −${c.removed})`);
172
- if (c.untracked) bits.push(`${c.untracked} new untracked`);
174
+ if (c.files) bits.push(c.files === 1 ? t('{n} file changed (+{added} −{removed})', { n: 1, added: c.added, removed: c.removed }) : t('{n} files changed (+{added} −{removed})', { n: c.files, added: c.added, removed: c.removed }));
175
+ if (c.untracked) bits.push(t('{n} new untracked', { n: c.untracked }));
173
176
  return bits.join(' · ');
174
177
  }
175
178
 
@@ -5,6 +5,7 @@
5
5
  // approvals.js; this file draws it and hands the three answers back.
6
6
  import { remaining, asker, allowSessionLabel, VERDICTS, OUTCOME_WORD } from './approvals.js';
7
7
  import { AskForms } from './ask.js';
8
+ import { t } from './i18n.js';
8
9
 
9
10
  const SVG = 'http://www.w3.org/2000/svg';
10
11
  const RING = 2 * Math.PI * 15; // the countdown ring's circumference, r = 15
@@ -65,11 +66,11 @@ export class Dock {
65
66
 
66
67
  const text = mk('div', 'dock-text');
67
68
  const head = mk('div', 'dock-head');
68
- const word = mk('span', 'dock-word', 'Needs you');
69
+ const word = mk('span', 'dock-word', t('Needs you'));
69
70
  // Who asked is the way to them: it brings that agent's card into view.
70
71
  const who = mk('button', 'dock-who');
71
72
  who.type = 'button';
72
- who.title = 'Show on the canvas';
73
+ who.title = t('Show on the canvas');
73
74
  who.addEventListener('click', () => { if (this.current) this.onLocate(this.current); });
74
75
  const tool = mk('span', 'dock-tool');
75
76
  const where = mk('span', 'dock-where');
@@ -84,12 +85,12 @@ export class Dock {
84
85
  const nav = mk('div', 'dock-nav');
85
86
  const prev = mk('button', 'btn icon sm ghost dock-step');
86
87
  prev.type = 'button';
87
- prev.setAttribute('aria-label', 'Previous request');
88
+ prev.setAttribute('aria-label', t('Previous request'));
88
89
  prev.innerHTML = '<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M10 4l-4 4 4 4"/></svg>';
89
90
  const count = mk('span', 'dock-count');
90
91
  const next = mk('button', 'btn icon sm ghost dock-step');
91
92
  next.type = 'button';
92
- next.setAttribute('aria-label', 'Next request');
93
+ next.setAttribute('aria-label', t('Next request'));
93
94
  next.innerHTML = '<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M6 4l4 4-4 4"/></svg>';
94
95
  prev.addEventListener('click', () => this.step(-1));
95
96
  next.addEventListener('click', () => this.step(1));
@@ -192,14 +193,14 @@ export class Dock {
192
193
  return;
193
194
  }
194
195
  this.root.dataset.state = 'waiting';
195
- p.word.textContent = 'Needs you';
196
+ p.word.textContent = t('Needs you');
196
197
 
197
198
  p.who.textContent = asker(item);
198
- p.who.title = item.agentType ? `${item.agentType} — show on the canvas` : 'Show on the canvas';
199
+ p.who.title = item.agentType ? t('{type} — show on the canvas', { type: item.agentType }) : t('Show on the canvas');
199
200
  p.tool.textContent = item.toolName;
200
201
  const where = this.where(item);
201
202
  p.where.hidden = !where;
202
- p.where.textContent = where ? `in ${where}` : '';
203
+ p.where.textContent = where ? t('in {name}', { name: where }) : '';
203
204
  // A question or a plan: its form, in place of the command and the three buttons.
204
205
  const form = this.forms.get(item);
205
206
  p.ask.hidden = !form;
@@ -207,37 +208,37 @@ export class Dock {
207
208
  // Put in once and left there: a form taken out and put back on every tick would lose the caret in it.
208
209
  if (this.shownForm !== form) { p.ask.replaceChildren(...(form ? [form] : [])); this.shownForm = form; }
209
210
  this.root.dataset.ask = form ? item.ask.kind : '';
210
- p.tool.textContent = form ? (item.ask.kind === 'plan' ? 'wants its plan approved' : 'asks') : item.toolName;
211
+ p.tool.textContent = form ? (item.ask.kind === 'plan' ? t('wants its plan approved') : t('asks')) : item.toolName;
211
212
  p.cmd.hidden = !item.detail || Boolean(form);
212
213
  p.cmd.textContent = item.detail ?? '';
213
214
  p.cmd.classList.toggle('open', this.open);
214
215
  p.cmd.setAttribute('aria-expanded', String(this.open));
215
- p.cmd.title = this.open ? 'Show on one line' : 'Show the whole command';
216
+ p.cmd.title = this.open ? t('Show on one line') : t('Show the whole command');
216
217
 
217
218
  const r = remaining(item, this.now());
218
219
  if (r.known) {
219
220
  p.ring.style.strokeDasharray = `${(r.fraction * RING).toFixed(1)} ${RING.toFixed(1)}`;
220
- p.secs.textContent = `${r.left}s`;
221
- p.timer.setAttribute('aria-label', `Auto-deny in ${r.left} seconds`);
222
- p.note.textContent = form ? `Refused in ${r.left}s if nobody answers` : `Auto-deny in ${r.left}s if nobody answers`;
221
+ p.secs.textContent = t('{n}s', { n: r.left });
222
+ p.timer.setAttribute('aria-label', t('Auto-deny in {n} seconds', { n: r.left }));
223
+ p.note.textContent = form ? t('Refused in {n}s if nobody answers', { n: r.left }) : t('Auto-deny in {n}s if nobody answers', { n: r.left });
223
224
  } else {
224
225
  // The server did not say how long the hook waits. No clock is better than an invented one.
225
226
  p.ring.style.strokeDasharray = `0 ${RING.toFixed(1)}`;
226
227
  p.secs.textContent = '?';
227
- p.timer.setAttribute('aria-label', 'Time left not measured');
228
- p.note.textContent = 'Time left not measured — it is denied if nobody answers';
228
+ p.timer.setAttribute('aria-label', t('Time left not measured'));
229
+ p.note.textContent = t('Time left not measured — it is denied if nobody answers');
229
230
  }
230
231
 
231
232
  p.nav.hidden = this.items.length < 2;
232
- p.count.textContent = `${this.at + 1} of ${this.items.length}`;
233
- p.nav.setAttribute('aria-label', `Request ${this.at + 1} of ${this.items.length}`);
233
+ p.count.textContent = t('{at} of {n}', { at: this.at + 1, n: this.items.length });
234
+ p.nav.setAttribute('aria-label', t('Request {at} of {n}', { at: this.at + 1, n: this.items.length }));
234
235
 
235
236
  const sent = this.busy.has(item.key);
236
237
  p.always.textContent = allowSessionLabel(item.toolName);
237
- p.always.title = `Allow this call, and stop asking here about ${item.toolName} for the rest of the session. Later calls are not approved for Claude Code: it still applies its own checks to them.`;
238
+ p.always.title = t('Allow this call, and stop asking here about {tool} for the rest of the session. Later calls are not approved for Claude Code: it still applies its own checks to them.', { tool: item.toolName });
238
239
  for (const b of [p.deny, p.always, p.allow]) b.disabled = sent;
239
240
  this.root.setAttribute('aria-label', form
240
- ? `Waiting for you: ${asker(item)} ${item.ask.kind === 'plan' ? 'wants its plan approved' : 'asks a question'}`
241
- : `Waiting for approval: ${asker(item)} wants to run ${item.toolName}`);
241
+ ? (item.ask.kind === 'plan' ? t('Waiting for you: {who} wants its plan approved', { who: asker(item) }) : t('Waiting for you: {who} asks a question', { who: asker(item) }))
242
+ : t('Waiting for approval: {who} wants to run {tool}', { who: asker(item), tool: item.toolName }));
242
243
  }
243
244
  }