k0-board 0.3.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 (82) 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 +426 -1
  21. package/README.md +1021 -119
  22. package/bin/k0-board.js +81 -11
  23. package/menubar/K0MenuBar.swift +35 -5
  24. package/package.json +13 -1
  25. package/platform/contract.js +37 -6
  26. package/platform/darwin/index.js +1 -0
  27. package/platform/darwin/shell.js +4 -1
  28. package/platform/darwin/terminal.js +230 -56
  29. package/platform/index.js +3 -1
  30. package/platform/linux/index.js +14 -0
  31. package/platform/linux/shell.js +10 -1
  32. package/platform/linux/tray.py +10 -10
  33. package/platform/shared/command.js +19 -3
  34. package/platform/shared/tmux.js +32 -14
  35. package/platform/win32/index.js +6 -2
  36. package/platform/win32/shell.js +4 -1
  37. package/platform/win32/terminal.js +24 -8
  38. package/platform/win32/tray.ps1 +13 -13
  39. package/server/backlog.js +618 -0
  40. package/server/changelog.js +13 -13
  41. package/server/db.js +1800 -146
  42. package/server/files.js +156 -26
  43. package/server/git.js +53 -14
  44. package/server/idle.js +15 -13
  45. package/server/index.js +1286 -189
  46. package/server/launcher.js +122 -14
  47. package/server/machine.js +10 -1
  48. package/server/mirror.js +1048 -0
  49. package/server/prices.js +59 -0
  50. package/server/projects.js +57 -12
  51. package/server/servers.js +22 -1
  52. package/server/sessions.js +5 -5
  53. package/server/settings.js +25 -2
  54. package/server/update.js +324 -0
  55. package/server/usage.js +642 -0
  56. package/server/watcher.js +67 -13
  57. package/server/worktree.js +600 -0
  58. package/server/writer.js +98 -26
  59. package/web/awake.js +62 -0
  60. package/web/base.css +4 -0
  61. package/web/board.css +404 -16
  62. package/web/board.js +1182 -152
  63. package/web/changelog.js +3 -3
  64. package/web/conf.js +118 -0
  65. package/web/files.css +206 -1
  66. package/web/files.html +10 -0
  67. package/web/files.js +533 -75
  68. package/web/fuzzy.js +73 -21
  69. package/web/index.html +109 -12
  70. package/web/json.js +145 -0
  71. package/web/list.css +536 -0
  72. package/web/list.js +1015 -0
  73. package/web/mentions.js +27 -9
  74. package/web/recency.js +10 -10
  75. package/web/units.js +1 -1
  76. package/web/usage.css +169 -0
  77. package/web/usage.html +78 -0
  78. package/web/usage.js +447 -0
  79. package/web/view.js +22 -3
  80. package/web/whatsnew.css +211 -0
  81. package/web/whatsnew.html +49 -0
  82. 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: {
@@ -65,6 +66,7 @@ const unsupported = {
65
66
  },
66
67
  shell: {
67
68
  revealInFileManager: async () => {},
69
+ openInFileManager: async () => {},
68
70
  openBrowser: async () => {},
69
71
  findChrome: () => null,
70
72
  homeSkipList: () => new Set(),
@@ -24,6 +24,7 @@ export const capabilities = {
24
24
  service: service.capabilities(),
25
25
  tray: !!which('python3'),
26
26
  revealInFileManager: shell.capabilities().revealInFileManager,
27
+ openInFileManager: shell.capabilities().openInFileManager,
27
28
  }
28
29
 
29
30
  export const notes = {
@@ -51,6 +52,9 @@ export const notes = {
51
52
  'Font size belongs to your terminal emulator and every one of them spells it differently, ' +
52
53
  'so driving mode enlarges the board but not the terminals. Set a bigger font in your ' +
53
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.',
54
58
  ...(capabilities.tray
55
59
  ? {}
56
60
  : { tray: 'python3 with PyGObject is needed for a tray icon. The board works without it.' }),
@@ -61,6 +65,16 @@ export const notes = {
61
65
  'Neither ss nor lsof was found, so k0 cannot see which port a dev server is listening ' +
62
66
  'on. Install iproute2 (for ss) and the globe will know where to send you.',
63
67
  }),
68
+ ...(capabilities.openInFileManager
69
+ ? {}
70
+ : {
71
+ revealInFileManager:
72
+ 'xdg-open was not found, so k0 has no way to hand a file or a folder to your file ' +
73
+ 'manager. Install xdg-utils and the button will work.',
74
+ openInFileManager:
75
+ 'xdg-open was not found, so k0 has no way to hand a file or a folder to your file ' +
76
+ 'manager. Install xdg-utils and the button will work.',
77
+ }),
64
78
  ...(capabilities.servers.adopt
65
79
  ? {}
66
80
  : {
@@ -31,6 +31,15 @@ export async function revealInFileManager(abs) {
31
31
  if (open) await runQuiet(open, [path.dirname(abs)])
32
32
  }
33
33
 
34
+ /**
35
+ * Opening a directory needs none of the dance above: every desktop that has a file manager at
36
+ * all hands a directory to it through xdg-open.
37
+ */
38
+ export async function openInFileManager(abs) {
39
+ const open = XDG_OPEN()
40
+ if (open) await runQuiet(open, [abs])
41
+ }
42
+
34
43
  export const openBrowser = (url) => run(XDG_OPEN(), [url], { timeout: 4000 })
35
44
 
36
45
  const CHROME_CANDIDATES = [
@@ -94,4 +103,4 @@ export function findClaude() {
94
103
  return claudeBin
95
104
  }
96
105
 
97
- export const capabilities = () => ({ revealInFileManager: !!XDG_OPEN() })
106
+ export const capabilities = () => ({ revealInFileManager: !!XDG_OPEN(), openInFileManager: !!XDG_OPEN() })
@@ -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),