k0-board 0.4.0 → 0.5.0

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 (73) hide show
  1. package/.claude/skills/k0-changelog/SKILL.md +2 -2
  2. package/.claude/skills/k0-discuss/SKILL.md +105 -0
  3. package/.claude/skills/k0-discuss/references/rounds.md +280 -0
  4. package/.claude/skills/k0-epic/SKILL.md +196 -0
  5. package/.claude/skills/k0-links/.claude-plugin/plugin.json +6 -0
  6. package/.claude/skills/k0-links/hooks/hooks.json +1 -0
  7. package/.claude/skills/k0-links/hooks/links.js +128 -0
  8. package/.claude/skills/k0-links/hooks/register.js +204 -0
  9. package/.claude/skills/k0-next/SKILL.md +82 -0
  10. package/.claude/skills/k0-order/SKILL.md +123 -0
  11. package/.claude/skills/k0-plan/SKILL.md +156 -0
  12. package/.claude/skills/k0-split/SKILL.md +169 -0
  13. package/.claude/skills/k0-story/SKILL.md +108 -0
  14. package/.claude/skills/k0-ultracode/SKILL.md +251 -0
  15. package/.claude/skills/k0-ultracode/references/agent-brief.md +71 -0
  16. package/.claude/skills/k0-ultracode/references/modes.md +49 -0
  17. package/.claude/skills/k0-verify/SKILL.md +219 -0
  18. package/.claude/skills/k0-whatsnew/SKILL.md +95 -0
  19. package/.claude/skills/k0-work/SKILL.md +212 -0
  20. package/CHANGELOG.md +361 -1
  21. package/README.md +931 -111
  22. package/bin/k0-board.js +79 -11
  23. package/menubar/K0MenuBar.swift +35 -5
  24. package/package.json +13 -1
  25. package/platform/contract.js +30 -5
  26. package/platform/darwin/terminal.js +230 -56
  27. package/platform/index.js +2 -1
  28. package/platform/linux/index.js +3 -0
  29. package/platform/linux/tray.py +10 -10
  30. package/platform/shared/command.js +19 -3
  31. package/platform/shared/tmux.js +32 -14
  32. package/platform/win32/index.js +5 -2
  33. package/platform/win32/terminal.js +24 -8
  34. package/platform/win32/tray.ps1 +13 -13
  35. package/server/backlog.js +618 -0
  36. package/server/changelog.js +13 -13
  37. package/server/db.js +1800 -146
  38. package/server/files.js +60 -21
  39. package/server/git.js +53 -14
  40. package/server/idle.js +15 -13
  41. package/server/index.js +1251 -186
  42. package/server/launcher.js +122 -14
  43. package/server/machine.js +10 -1
  44. package/server/mirror.js +1048 -0
  45. package/server/prices.js +59 -0
  46. package/server/projects.js +57 -12
  47. package/server/servers.js +22 -1
  48. package/server/sessions.js +5 -5
  49. package/server/settings.js +25 -2
  50. package/server/update.js +324 -0
  51. package/server/usage.js +642 -0
  52. package/server/watcher.js +67 -13
  53. package/server/worktree.js +600 -0
  54. package/server/writer.js +98 -26
  55. package/web/awake.js +62 -0
  56. package/web/base.css +4 -0
  57. package/web/board.css +396 -16
  58. package/web/board.js +1182 -152
  59. package/web/changelog.js +3 -3
  60. package/web/files.js +73 -16
  61. package/web/index.html +109 -12
  62. package/web/list.css +536 -0
  63. package/web/list.js +1015 -0
  64. package/web/mentions.js +27 -9
  65. package/web/recency.js +10 -10
  66. package/web/units.js +1 -1
  67. package/web/usage.css +169 -0
  68. package/web/usage.html +78 -0
  69. package/web/usage.js +447 -0
  70. package/web/view.js +22 -3
  71. package/web/whatsnew.css +211 -0
  72. package/web/whatsnew.html +49 -0
  73. package/web/whatsnew.js +339 -0
@@ -17,9 +17,17 @@ const PBPASTE = () => which('pbpaste', ['/usr/bin/pbpaste'])
17
17
  /** Escape for an AppleScript string literal. */
18
18
  const asq = (s) => String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
19
19
 
20
- /** How much of the free screen area a window takes: the rest is margin. */
20
+ /** How much of the free screen area a window takes unless somebody asks for more. */
21
21
  const COVERAGE = 0.86
22
22
 
23
+ /**
24
+ * How long a pass over the open windows may take. The five seconds `run` gives every other
25
+ * command is not enough for this one: Terminal answers in its own time, and a pass killed
26
+ * halfway through leaves half the windows changed and the other half as they were — which is
27
+ * precisely the failure this file used to have.
28
+ */
29
+ const PASS_TIMEOUT = 20000
30
+
23
31
  /** In driving mode the terminal has to be readable from across the room. */
24
32
  export const DRIVING_FONT_SIZE = 22
25
33
 
@@ -38,13 +46,17 @@ export async function defaultFontSize() {
38
46
 
39
47
  /**
40
48
  * The piece of AppleScript that decides where a window goes and how big it is. It is written
41
- * once because two callers need it — the one that opens a new window and the one that puts
42
- * them back after the screen changes — and the rule has to be the same for both: otherwise
43
- * the first time a monitor is unplugged every window would change size for no reason.
49
+ * once because three callers need it — the one that opens a new window, the one that puts them
50
+ * back after the screen changes, and the one that follows the mode — and the rule has to be the
51
+ * same for all of them: otherwise the first time a monitor is unplugged every window would
52
+ * change size for no reason.
53
+ *
54
+ * `coverage` is how much of the free area the window takes: the usual share, or all of it when
55
+ * the mode wants windows readable from across the room.
44
56
  *
45
57
  * Leaves `x1`, `y1`, `w` and `h` behind, which is what `bounds` needs.
46
58
  */
47
- const GEOMETRY = `
59
+ const geometry = (coverage = COVERAGE) => `
48
60
  use framework "AppKit"
49
61
  use scripting additions
50
62
 
@@ -53,8 +65,8 @@ use scripting additions
53
65
  set scr to current application's NSScreen's mainScreen()
54
66
  set frameH to item 2 of item 2 of (scr's frame() as list)
55
67
  set {{vx, vy}, {vw, vh}} to (scr's visibleFrame() as list)
56
- set w to round (vw * ${COVERAGE})
57
- set h to round (vh * ${COVERAGE})
68
+ set w to round (vw * ${coverage})
69
+ set h to round (vh * ${coverage})
58
70
  set x1 to round (vx + (vw - w) / 2)
59
71
  -- NSScreen counts from the bottom, Terminal from the top: this converts between them.
60
72
  set y1 to round (frameH - (vy + vh) + (vh - h) / 2)
@@ -74,9 +86,9 @@ set y1 to round (frameH - (vy + vh) + (vh - h) / 2)
74
86
  * - the window is sized BEFORE the command runs, so Claude Code's interface is born at the
75
87
  * right size instead of having to redraw itself halfway through starting up.
76
88
  */
77
- export async function open({ command, title, fontSize }) {
89
+ export async function open({ command, title, fontSize, coverage }) {
78
90
  const font = fontSize ?? (await defaultFontSize())
79
- const script = `${GEOMETRY}
91
+ const script = `${geometry(coverage)}
80
92
  tell application "Terminal"
81
93
  activate
82
94
  do script ""
@@ -84,7 +96,7 @@ tell application "Terminal"
84
96
  set font size of tab 1 of win to ${font}
85
97
  set bounds of win to {x1, y1, x1 + w, y1 + h}
86
98
  do script "${asq(command)}" in tab 1 of win
87
- -- The card's name in the title bar, not the command line with the session id in it.
99
+ -- The story's name in the title bar, not the command line with the session id in it.
88
100
  set custom title of tab 1 of win to "${asq(title)}"
89
101
  set winId to id of win
90
102
  end tell
@@ -96,65 +108,96 @@ return winId as string`
96
108
  const ids = (handles) => [...new Set((handles || []).map(Number).filter(Boolean))]
97
109
 
98
110
  /**
99
- * One `osascript` for every window, each in its own `try`.
111
+ * One `osascript` for the lot: it walks Terminal's OWN windows once and touches those whose id
112
+ * k0 asked about.
113
+ *
114
+ * The obvious shape is a `try` block per id, and it is the one that broke. k0 hands over the
115
+ * window id of every story it has ever opened — 218 of them on the board where this was found —
116
+ * and nearly all of them name a window closed weeks ago. Each dead id costs AppleScript an error
117
+ * to raise and swallow, about 33 milliseconds of it: the script took 7.4 seconds against the 5
118
+ * second limit on `run`, was killed halfway through, and the `catch` below read that as
119
+ * "Terminal is not running". Half the windows had changed, half had not, and nothing anywhere
120
+ * said so. It worked in the morning and stopped in the afternoon because the list only grows.
100
121
  *
101
- * The per-window `try` is there so that one window closed by hand in the meantime does not
102
- * take all the others down with it. And there is deliberately **no `activate`**: opening a
103
- * window needs Terminal in front, touching one that is already open does not — otherwise
104
- * every time you switch driving mode on or plug in a monitor, Terminal would jump in front
105
- * of whatever you were doing.
122
+ * Walking the windows that are actually open costs about a second and stops growing with the
123
+ * board's history: an id naming nothing simply never matches. The count comes back from
124
+ * AppleScript itself instead of being assumed, so the caller learns how many windows really
125
+ * moved, and a failure comes back as a sentence instead of as a zero.
126
+ *
127
+ * There is deliberately no `activate`: opening a window needs Terminal in front, touching one
128
+ * that is already open does not — otherwise every mode you clicked would bring Terminal forward
129
+ * over whatever you were doing.
130
+ *
131
+ * @returns {Promise<{touched: number, error?: string}>}
106
132
  */
107
- async function forEachWindow(preamble, body, list) {
108
- const blocks = list.map((id) => `\n try${body(id)}\n end try`).join('')
133
+ async function eachWindow(preamble, body, list) {
109
134
  try {
110
- await run(OSASCRIPT(), ['-e', `${preamble}\ntell application "Terminal"${blocks}\nend tell`])
111
- return { touched: list.length }
112
- } catch {
113
- return { touched: 0 } // Terminal is not running at all: there is nothing to touch
135
+ const out = await run(OSASCRIPT(), ['-e', windowPass(preamble, body, list)], { timeout: PASS_TIMEOUT })
136
+ return { touched: Number(String(out).trim()) || 0 }
137
+ } catch (err) {
138
+ return { touched: 0, error: String(err?.message || err).split('\n')[0].trim() }
114
139
  }
115
140
  }
116
141
 
117
142
  /**
118
- * Changes the font size of windows that are already open — which is what happens when
119
- * driving mode goes on or off.
143
+ * The script itself, kept apart from the running of it so that a test can read the shape
144
+ * without a Terminal to drive: whether the pass is one walk over the windows or the old walk
145
+ * over the ids is the whole point of this file, and it is worth being able to check.
146
+ */
147
+ export const windowPass = (preamble, body, list) => `${preamble}
148
+ set wanted to {${list.join(', ')}}
149
+ set touched to 0
150
+ tell application "Terminal"
151
+ repeat with win in windows
152
+ if (id of win) is in wanted then${body('win')}
153
+ set touched to touched + 1
154
+ end if
155
+ end repeat
156
+ end tell
157
+ return touched as string`
158
+
159
+ /**
160
+ * Puts the windows k0 owns the way the mode wants them: the text at `fontSize`, the window at
161
+ * `coverage` of the free screen. Both in the same pass, deliberately.
120
162
  *
121
- * **Every window stays exactly where and as it is.** That is worth saying because it is not
122
- * free: Terminal.app keeps rows and columns when the font changes and resizes the window
123
- * accordingly — going from 12 to 22 more than doubles it. So the bounds are read first, the
124
- * font is changed, and the bounds are put back: **its own** bounds, not the centred ones.
125
- * k0's windows have rarely stayed where they were born — you move them, widen them, drag one
126
- * to the other monitor — and turning on driving mode is not the moment to sweep them all
127
- * back to the middle of the screen without being asked.
163
+ * They used to be two separate ideas. The font changed and the window was carefully put back
164
+ * exactly where it was, on the grounds that turning driving mode on is not the moment to sweep
165
+ * every window back to the middle of the screen. What that left behind was a window holding 22
166
+ * point text in a box measured for 12: the size of the text and the size of the window are one
167
+ * gesture, and doing half of it is worse than doing neither.
128
168
  *
129
- * What genuinely changes is how much text fits: at 22 points, fewer rows.
169
+ * The font goes first: Terminal keeps rows and columns when the font changes and resizes the
170
+ * window to match, so the bounds have to be set after it or they would be eaten.
171
+ *
172
+ * The price, said plainly because the old behaviour promised the opposite: a window you had
173
+ * dragged onto another screen comes back to the middle of the main one.
130
174
  */
131
- export async function setFont(handles, size) {
175
+ export async function applyMode(handles, { fontSize, coverage } = {}) {
132
176
  const list = ids(handles)
133
177
  if (!list.length) return { touched: 0 }
134
- const font = size ?? (await defaultFontSize())
135
- return forEachWindow(
136
- '',
137
- (id) => `
138
- set b to bounds of window id ${id}
139
- set font size of tab 1 of window id ${id} to ${font}
140
- set bounds of window id ${id} to b`,
178
+ const font = fontSize ?? (await defaultFontSize())
179
+ return eachWindow(
180
+ geometry(coverage),
181
+ (win) => `
182
+ set font size of tab 1 of ${win} to ${font}
183
+ set bounds of ${win} to {x1, y1, x1 + w, y1 + h}`,
141
184
  list
142
185
  )
143
186
  }
144
187
 
145
188
  /**
146
- * Puts the windows back in the middle of the screen at their usual size: what is needed when
147
- * the screen changes underneath them — a monitor plugged in or unplugged, a different
148
- * resolution — and the windows stay where they were, out of place or off screen.
189
+ * Puts the windows back in the middle of the screen at the size the mode asks for: what is
190
+ * needed when the screen changes underneath them — a monitor plugged in or unplugged, a
191
+ * different resolution — and the windows stay where they were, out of place or off screen.
149
192
  *
150
193
  * Here gathering them back to the centre is the point: they all end up where they were born,
151
194
  * as they were on the first day. The geometry is computed once for all of them, so none ends
152
195
  * up on a different screen from its siblings.
153
196
  */
154
- export async function relayout(handles) {
197
+ export async function relayout(handles, { coverage } = {}) {
155
198
  const list = ids(handles)
156
199
  if (!list.length) return { touched: 0 }
157
- return forEachWindow(GEOMETRY, (id) => `\n set bounds of window id ${id} to {x1, y1, x1 + w, y1 + h}`, list)
200
+ return eachWindow(geometry(coverage), (win) => `\n set bounds of ${win} to {x1, y1, x1 + w, y1 + h}`, list)
158
201
  }
159
202
 
160
203
  /**
@@ -179,30 +222,76 @@ export async function setTitle(handle, title) {
179
222
  }
180
223
  }
181
224
 
182
- /**
183
- * Brings the card's window back to the front, un-minimising it if it was parked.
184
- * Keeps you from losing track of which terminal is which.
185
- */
186
- export async function focus(handle) {
187
- const id = Number(handle)
188
- if (!id) return { ok: false, error: 'This card has no terminal window of its own' }
189
- const script = `
225
+ const raise = (id) => `
190
226
  tell application "Terminal"
191
227
  activate
192
228
  set w to window id ${id}
193
229
  set miniaturized of w to false
194
230
  set index of w to 1
195
231
  end tell`
232
+
233
+ /**
234
+ * The window whose tab is called `title`, as an id — or nothing.
235
+ *
236
+ * Terminal's window ids do not survive Terminal being quit and reopened, and a session detached
237
+ * into a window somebody rearranged can end up behind an id k0 wrote down and that now belongs to
238
+ * nothing. The custom title `open()` sets is the other name the window has, and it is the one
239
+ * that survives: this is how a window k0 lost is found again rather than declared gone.
240
+ */
241
+ async function byTitle(title) {
242
+ if (!title) return null
243
+ const script = `
244
+ tell application "Terminal"
245
+ repeat with w in windows
246
+ try
247
+ if custom title of tab 1 of w is "${asq(title)}" then return id of w as string
248
+ end try
249
+ end repeat
250
+ end tell
251
+ return ""`
196
252
  try {
197
- await run(OSASCRIPT(), ['-e', script])
198
- return { ok: true }
253
+ return String(await run(OSASCRIPT(), ['-e', script])).trim() || null
254
+ } catch {
255
+ return null
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Brings the story's window back to the front, un-minimising it if it was parked.
261
+ * Keeps you from losing track of which terminal is which.
262
+ *
263
+ * `title` is the story's name, and it is the second way of finding the window. When the id no
264
+ * longer answers, the window is looked for by name before anybody is told it is gone — and the id
265
+ * that was found comes back with the answer, so the caller can write down the one that works
266
+ * instead of asking twice for the same thing.
267
+ */
268
+ export async function focus(handle, title) {
269
+ const id = Number(handle)
270
+ if (id) {
271
+ try {
272
+ await run(OSASCRIPT(), ['-e', raise(id)])
273
+ return { ok: true }
274
+ } catch {
275
+ // Fall through: the id is stale, which is not the same as the window being gone.
276
+ }
277
+ }
278
+ const found = await byTitle(title)
279
+ if (!found) {
280
+ return {
281
+ ok: false,
282
+ error: id ? 'That window is gone' : 'This story has no terminal window of its own',
283
+ }
284
+ }
285
+ try {
286
+ await run(OSASCRIPT(), ['-e', raise(Number(found))])
287
+ return { ok: true, handle: found }
199
288
  } catch {
200
289
  return { ok: false, error: 'That window is gone' }
201
290
  }
202
291
  }
203
292
 
204
293
  /**
205
- * Closes a card's terminal: stops the session first, then closes the window.
294
+ * Closes a story's terminal: stops the session first, then closes the window.
206
295
  *
207
296
  * The order is not a detail. Closing a window that still has `claude` inside it makes macOS
208
297
  * put up the "terminate running processes" dialog and sit there waiting: the window does not
@@ -313,12 +402,97 @@ export async function type(text, handle) {
313
402
  }
314
403
  }
315
404
 
405
+ /** Who has the keyboard right now: the frontmost process, and Terminal's front window if it is Terminal. */
406
+ async function keyboard() {
407
+ let app = ''
408
+ try {
409
+ app = String(
410
+ await run(OSASCRIPT(), [
411
+ '-e',
412
+ 'tell application "System Events" to get name of first application process whose frontmost is true',
413
+ ])
414
+ ).trim()
415
+ } catch {
416
+ /* System Events not answering: nobody, as far as we can tell */
417
+ }
418
+ let window = null
419
+ if (app === 'Terminal') {
420
+ try {
421
+ window = Number(await run(OSASCRIPT(), ['-e', 'tell application "Terminal" to get id of front window'])) || null
422
+ } catch {
423
+ /* no window in front */
424
+ }
425
+ }
426
+ return { app, window }
427
+ }
428
+
429
+ /**
430
+ * Runs a slash command inside the session: `/rename Foo`.
431
+ *
432
+ * Only keystrokes will do. `do script` — what `type` uses — writes the line in one block, and
433
+ * Claude Code takes a block as a message: `/rename Foo` typed that way was answered by the
434
+ * model explaining that it cannot rename the session. Typed key by key through System Events it
435
+ * runs. That needs the Accessibility permission, the same one `paste` needs, so without it the
436
+ * answer is an honest no.
437
+ *
438
+ * Never by raising the window. Keystrokes go to whatever is in front, and this was first written
439
+ * to bring the window up, type, and hand the keyboard back — and while it did, what the person at
440
+ * the keyboard was typing into another window landed in the box in front of the command. So the
441
+ * command is typed only when that window already has the keyboard, which is to say when you are
442
+ * looking at it; the caller comes back later otherwise.
443
+ *
444
+ * Verified before Enter, never blindly. Claude Code talks to the terminal now and then, and a
445
+ * key that lands in the middle of that exchange comes out as junk in front of the slash —
446
+ * `ltr/rename Foo`, seen — which Claude Code then reads as a message. So after the keys the line
447
+ * under the cursor is read back, and Enter is pressed only if it is exactly the command; anything
448
+ * else is deleted, a keystroke per character, and the caller is told.
449
+ */
450
+ export async function command(text, handle, { ready, verify }) {
451
+ const id = Number(handle)
452
+ if (!id) return { sent: false, why: 'This story has no terminal window of its own' }
453
+ const now = await keyboard()
454
+ if (now.app !== 'Terminal' || now.window !== id) return { sent: false, why: 'The window is not in front' }
455
+ if (!ready(await readScreen(id))) return { sent: false, why: 'The input box is not free' }
456
+ // Takes away whatever is under the cursor. Generous on purpose: the box is empty once there is
457
+ // nothing left, and a Backspace on an empty box does nothing.
458
+ const clear = () =>
459
+ run(OSASCRIPT(), [
460
+ '-e',
461
+ `tell application "System Events"\n repeat ${text.length + 16} times\n key code 51\n end repeat\nend tell`,
462
+ ])
463
+ try {
464
+ let why = 'The keys did not land clean'
465
+ // Twice at most: the junk is a matter of timing, and the second go usually lands.
466
+ for (let attempt = 0; attempt < 2; attempt++) {
467
+ await run(OSASCRIPT(), ['-e', `tell application "System Events" to keystroke "${asq(text)}"`])
468
+ await new Promise((r) => setTimeout(r, 400))
469
+ if (verify(await readScreen(id))) {
470
+ // Enter, and proof that it went in: a key event is posted, not delivered, and one can
471
+ // miss — seen, with the command left sitting in the box.
472
+ for (let enter = 0; enter < 2; enter++) {
473
+ await run(OSASCRIPT(), ['-e', 'tell application "System Events" to key code 36'])
474
+ await new Promise((r) => setTimeout(r, 400))
475
+ if (!verify(await readScreen(id))) return { sent: true }
476
+ }
477
+ why = 'Enter did not go in'
478
+ }
479
+ // Wrong line under the cursor, or the right one that will not go: take it away rather than
480
+ // leave it — a box with a stray command in it is one nobody can type into.
481
+ await clear()
482
+ }
483
+ return { sent: false, why }
484
+ } catch (err) {
485
+ return { sent: false, why: String(err.stderr || err.message).trim() }
486
+ }
487
+ }
488
+
316
489
  export const capabilities = {
317
490
  windows: true,
318
491
  font: true,
319
492
  readScreen: true,
320
493
  pasteWithoutSending: true,
321
494
  title: true,
495
+ commands: true,
322
496
  }
323
497
 
324
498
  export { posixCommand as buildCommand } from '../shared/command.js'
package/platform/index.js CHANGED
@@ -33,13 +33,14 @@ const unsupported = {
33
33
  throw new Error(`k0 cannot open terminals on ${process.platform} yet.`)
34
34
  },
35
35
  setTitle: async () => false,
36
- setFont: async () => ({ touched: 0 }),
36
+ applyMode: async () => ({ touched: 0 }),
37
37
  relayout: async () => ({ touched: 0 }),
38
38
  focus: async () => ({ ok: false, error: `Not supported on ${process.platform}` }),
39
39
  close: async () => ({ closed: false }),
40
40
  readScreen: async () => null,
41
41
  paste: async () => ({ pasted: false }),
42
42
  type: async () => ({ written: false }),
43
+ command: async () => ({ sent: false, why: `Not supported on ${process.platform}` }),
43
44
  defaultFontSize: async () => 12,
44
45
  },
45
46
  power: {
@@ -52,6 +52,9 @@ export const notes = {
52
52
  'Font size belongs to your terminal emulator and every one of them spells it differently, ' +
53
53
  'so driving mode enlarges the board but not the terminals. Set a bigger font in your ' +
54
54
  'emulator’s own preferences if you want both.',
55
+ 'terminal.commands':
56
+ 'Renaming a story renames its Claude Code session only once the session has ended: k0 has ' +
57
+ 'not yet been seen to run /rename inside a live session through tmux, so it does not try.',
55
58
  ...(capabilities.tray
56
59
  ? {}
57
60
  : { tray: 'python3 with PyGObject is needed for a tray icon. The board works without it.' }),
@@ -152,7 +152,7 @@ def _forget(notification):
152
152
  _shown.remove(notification)
153
153
 
154
154
 
155
- def notify(card_id, title, body):
155
+ def notify(story_id, title, body):
156
156
  """Say that a session is waiting, and let the click take you to it.
157
157
 
158
158
  The action is named `default`, which is what a notification daemon calls the click on the
@@ -163,7 +163,7 @@ def notify(card_id, title, body):
163
163
  if Notify is not None:
164
164
  try:
165
165
  note = Notify.Notification.new(title, body, None)
166
- note.add_action("default", "Open", lambda *_: api(f"/api/card/{card_id}/focus", {}))
166
+ note.add_action("default", "Open", lambda *_: api(f"/api/story/{story_id}/focus", {}))
167
167
  note.connect("closed", _forget)
168
168
  _shown.append(note)
169
169
  if note.show():
@@ -212,12 +212,12 @@ class Tray:
212
212
  return True
213
213
 
214
214
  def announce(self, waiting):
215
- """Notify about cards that have just started waiting, not about every one every time."""
216
- now = {(card["id"], card["status"]) for card in waiting}
215
+ """Notify about stories that have just started waiting, not about every one every time."""
216
+ now = {(story["id"], story["status"]) for story in waiting}
217
217
  if not self.first_pass:
218
- for card in waiting:
219
- if (card["id"], card["status"]) not in self.seen:
220
- notify(card["id"], LABELS.get(card["status"], "k0"), card.get("title", ""))
218
+ for story in waiting:
219
+ if (story["id"], story["status"]) not in self.seen:
220
+ notify(story["id"], LABELS.get(story["status"], "k0"), story.get("title", ""))
221
221
  self.seen = now
222
222
  self.first_pass = False
223
223
 
@@ -231,9 +231,9 @@ class Tray:
231
231
  elif not waiting:
232
232
  self.add_item("Nothing waiting for you", None, enabled=False)
233
233
  else:
234
- for card in waiting:
235
- label = f"{LABELS.get(card['status'], card['status'])} — {card.get('title', '')}"
236
- self.add_item(label, lambda _w, c=card: api(f"/api/card/{c['id']}/focus", {}))
234
+ for story in waiting:
235
+ label = f"{LABELS.get(story['status'], story['status'])} — {story.get('title', '')}"
236
+ self.add_item(label, lambda _w, c=story: api(f"/api/story/{c['id']}/focus", {}))
237
237
 
238
238
  self.menu.append(Gtk.SeparatorMenuItem())
239
239
  for value, label in MODES:
@@ -8,18 +8,34 @@
8
8
  * a prompt passed as an argument can contain anything at all.
9
9
  */
10
10
 
11
+ /**
12
+ * The environment the program is started with, as name/value pairs. A value is quoted like
13
+ * everything else; a name cannot be quoted at all — it sits bare in front of an `=` — so anything
14
+ * that is not plainly a name is refused before it can reach a shell.
15
+ */
16
+ const envPairs = (env = {}) =>
17
+ Object.entries(env).map(([name, value]) => {
18
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error(`not an environment variable name: ${JSON.stringify(name)}`)
19
+ return [name, value]
20
+ })
21
+
11
22
  /** POSIX single quotes: everything is literal inside them, and a quote ends and reopens them. */
12
23
  export const posixQuote = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`
13
24
 
14
- export const posixCommand = ({ cwd, bin, args = [] }) =>
15
- ['cd', posixQuote(cwd), '&&', posixQuote(bin), ...args.map(posixQuote)].join(' ')
25
+ // Through `env` rather than a bare `NAME=value` in front: the terminal hands the line to whatever
26
+ // shell the user logs in with, and `env` reads the same in sh, zsh and fish alike.
27
+ export const posixCommand = ({ cwd, bin, args = [], env }) => {
28
+ const pairs = envPairs(env).map(([name, value]) => `${name}=${posixQuote(value)}`)
29
+ return ['cd', posixQuote(cwd), '&&', ...(pairs.length ? ['env', ...pairs] : []), posixQuote(bin), ...args.map(posixQuote)].join(' ')
30
+ }
16
31
 
17
32
  /** PowerShell single quotes: same idea, but a quote is escaped by doubling it. */
18
33
  export const powershellQuote = (s) => `'${String(s).replace(/'/g, "''")}'`
19
34
 
20
- export const powershellCommand = ({ cwd, bin, args = [] }) =>
35
+ export const powershellCommand = ({ cwd, bin, args = [], env }) =>
21
36
  [
22
37
  `Set-Location ${powershellQuote(cwd)};`,
38
+ ...envPairs(env).map(([name, value]) => `$env:${name}=${powershellQuote(value)};`),
23
39
  '&',
24
40
  powershellQuote(bin),
25
41
  ...args.map(powershellQuote),
@@ -36,6 +36,9 @@ const EMULATORS = [
36
36
  { bin: 'xterm', args: (cmd) => ['-e', ...cmd] },
37
37
  ]
38
38
 
39
+ /** How much of the work area a window takes unless the mode asks for more. */
40
+ const COVERAGE = 0.86
41
+
39
42
  /**
40
43
  * Which emulator to open. `K0_TERMINAL` wins, because no list of ours will ever cover every
41
44
  * terminal anybody uses, and being wrong about it should not mean being stuck with it.
@@ -83,10 +86,10 @@ export async function open({ command, title }) {
83
86
  const session = sessionName()
84
87
 
85
88
  // The session runs the command directly rather than a shell that then runs it: when Claude
86
- // Code exits, the session ends, which is what makes a finished card's terminal disappear
89
+ // Code exits, the session ends, which is what makes a finished story's terminal disappear
87
90
  // instead of leaving an idle shell behind.
88
91
  await run(tmux, ['new-session', '-d', '-s', session, '-n', title || session, command])
89
- // Off by default, and worth turning on: it is what puts the card's name in the window's
92
+ // Off by default, and worth turning on: it is what puts the story's name in the window's
90
93
  // title bar rather than "tmux".
91
94
  await runQuiet(tmux, ['set-option', '-t', session, 'set-titles', 'on'])
92
95
  await runQuiet(tmux, ['set-option', '-t', session, 'set-titles-string', title || session])
@@ -183,8 +186,12 @@ async function windowId(handle) {
183
186
  return String(out).trim().split('\n').filter(Boolean).pop() || null
184
187
  }
185
188
 
186
- export async function focus(handle) {
187
- if (!handle) return { ok: false, error: 'This card has no terminal of its own' }
189
+ /**
190
+ * `title` is accepted and unused: here the handle IS the name tmux put on the window, so the
191
+ * lookup below is already the search by name that macOS has to fall back to.
192
+ */
193
+ export async function focus(handle, title) {
194
+ if (!handle) return { ok: false, error: 'This story has no terminal of its own' }
188
195
  if (!canPlaceWindows()) return { ok: false, error: 'k0 cannot raise windows on this desktop' }
189
196
  const id = await windowId(handle)
190
197
  if (!id) return { ok: false, error: 'That window is gone' }
@@ -195,16 +202,17 @@ export async function focus(handle) {
195
202
  /**
196
203
  * Geometry, best effort. There is no NSScreen here to ask for the usable area net of panels
197
204
  * and docks, so the work area comes from the window manager's own `_NET_WORKAREA` by way of
198
- * xdotool, and the same 86% margin as everywhere else is applied to it.
205
+ * xdotool, and the same share of it as everywhere else is applied — all of it when the mode asks
206
+ * for windows readable from across the room.
199
207
  */
200
- export async function relayout(handles) {
208
+ export async function relayout(handles, { coverage = COVERAGE } = {}) {
201
209
  if (!canPlaceWindows()) return { touched: 0 }
202
210
  const xdotool = XDOTOOL()
203
211
  const geom = await runQuiet(xdotool, ['getdisplaygeometry'])
204
212
  const [sw, sh] = String(geom).trim().split(/\s+/).map(Number)
205
213
  if (!sw || !sh) return { touched: 0 }
206
- const w = Math.round(sw * 0.86)
207
- const h = Math.round(sh * 0.86)
214
+ const w = Math.round(sw * coverage)
215
+ const h = Math.round(sh * coverage)
208
216
  const x = Math.round((sw - w) / 2)
209
217
  const y = Math.round((sh - h) / 2)
210
218
  let touched = 0
@@ -219,24 +227,34 @@ export async function relayout(handles) {
219
227
  }
220
228
 
221
229
  /**
222
- * Font size belongs to the emulator, and every emulator spells it differently — some only in
223
- * a config file that is read at startup. There is no honest generic answer, so k0 says it
224
- * cannot and driving mode changes only the board's own text.
230
+ * What the mode asks for, as far as this platform can give it: the window takes its share of the
231
+ * screen. Font size belongs to the emulator, and every emulator spells it differently — some only
232
+ * in a config file read at startup. There is no honest generic answer, so k0 says it cannot: here
233
+ * driving mode makes the windows bigger and leaves the text to the emulator.
225
234
  */
226
- export async function setFont() {
227
- return { touched: 0 }
228
- }
235
+ export const applyMode = (handles, { coverage } = {}) => relayout(handles, { coverage })
229
236
 
230
237
  export async function defaultFontSize() {
231
238
  return 12
232
239
  }
233
240
 
241
+ /**
242
+ * Not yet. `send-keys` would probably do it, but it has not been seen to: whether Claude Code
243
+ * takes a slash command that arrives that way, or reads it as a message the way it reads the
244
+ * block `type` writes on macOS, is exactly the kind of thing this file does not guess at. Until
245
+ * somebody checks on a Linux machine, a live session keeps its name until it ends.
246
+ */
247
+ export async function command() {
248
+ return { sent: false, why: 'Running a command inside a live session is not available with tmux yet' }
249
+ }
250
+
234
251
  export const capabilities = () => ({
235
252
  windows: canPlaceWindows(),
236
253
  font: false,
237
254
  readScreen: !!TMUX(),
238
255
  pasteWithoutSending: !!TMUX(),
239
256
  title: !!TMUX(),
257
+ commands: false,
240
258
  })
241
259
 
242
260
  export { posixCommand as buildCommand } from './command.js'
@@ -34,10 +34,13 @@ export const notes = {
34
34
  'Windows gives no way to read what is printed inside another process’s console, so k0 ' +
35
35
  'cannot tell exactly when Claude Code has finished starting: it waits a fixed moment ' +
36
36
  'instead. On a slow machine the prompt can arrive early — press Escape, then paste it ' +
37
- 'again from the card.',
37
+ 'again from the story.',
38
38
  'terminal.title':
39
- 'Windows Terminal owns its tab titles, so renaming a card does not rename a window that ' +
39
+ 'Windows Terminal owns its tab titles, so renaming a story does not rename a window that ' +
40
40
  'is already open. The new name shows up next time the session starts.',
41
+ 'terminal.commands':
42
+ 'Renaming a story renames its Claude Code session only once the session has ended: k0 ' +
43
+ 'cannot read what is on a terminal’s screen here, so it will not type /rename into one blind.',
41
44
  'terminal.font':
42
45
  'Font size lives in your terminal’s own profile, which k0 will not rewrite, so driving ' +
43
46
  'mode enlarges the board but not the terminals.',