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.
- package/.claude/skills/k0-changelog/SKILL.md +2 -2
- package/.claude/skills/k0-discuss/SKILL.md +105 -0
- package/.claude/skills/k0-discuss/references/rounds.md +280 -0
- package/.claude/skills/k0-epic/SKILL.md +196 -0
- package/.claude/skills/k0-links/.claude-plugin/plugin.json +6 -0
- package/.claude/skills/k0-links/hooks/hooks.json +1 -0
- package/.claude/skills/k0-links/hooks/links.js +128 -0
- package/.claude/skills/k0-links/hooks/register.js +204 -0
- package/.claude/skills/k0-next/SKILL.md +82 -0
- package/.claude/skills/k0-order/SKILL.md +123 -0
- package/.claude/skills/k0-plan/SKILL.md +156 -0
- package/.claude/skills/k0-split/SKILL.md +169 -0
- package/.claude/skills/k0-story/SKILL.md +108 -0
- package/.claude/skills/k0-ultracode/SKILL.md +251 -0
- package/.claude/skills/k0-ultracode/references/agent-brief.md +71 -0
- package/.claude/skills/k0-ultracode/references/modes.md +49 -0
- package/.claude/skills/k0-verify/SKILL.md +219 -0
- package/.claude/skills/k0-whatsnew/SKILL.md +95 -0
- package/.claude/skills/k0-work/SKILL.md +212 -0
- package/CHANGELOG.md +426 -1
- package/README.md +1021 -119
- package/bin/k0-board.js +81 -11
- package/menubar/K0MenuBar.swift +35 -5
- package/package.json +13 -1
- package/platform/contract.js +37 -6
- package/platform/darwin/index.js +1 -0
- package/platform/darwin/shell.js +4 -1
- package/platform/darwin/terminal.js +230 -56
- package/platform/index.js +3 -1
- package/platform/linux/index.js +14 -0
- package/platform/linux/shell.js +10 -1
- package/platform/linux/tray.py +10 -10
- package/platform/shared/command.js +19 -3
- package/platform/shared/tmux.js +32 -14
- package/platform/win32/index.js +6 -2
- package/platform/win32/shell.js +4 -1
- package/platform/win32/terminal.js +24 -8
- package/platform/win32/tray.ps1 +13 -13
- package/server/backlog.js +618 -0
- package/server/changelog.js +13 -13
- package/server/db.js +1800 -146
- package/server/files.js +156 -26
- package/server/git.js +53 -14
- package/server/idle.js +15 -13
- package/server/index.js +1286 -189
- package/server/launcher.js +122 -14
- package/server/machine.js +10 -1
- package/server/mirror.js +1048 -0
- package/server/prices.js +59 -0
- package/server/projects.js +57 -12
- package/server/servers.js +22 -1
- package/server/sessions.js +5 -5
- package/server/settings.js +25 -2
- package/server/update.js +324 -0
- package/server/usage.js +642 -0
- package/server/watcher.js +67 -13
- package/server/worktree.js +600 -0
- package/server/writer.js +98 -26
- package/web/awake.js +62 -0
- package/web/base.css +4 -0
- package/web/board.css +404 -16
- package/web/board.js +1182 -152
- package/web/changelog.js +3 -3
- package/web/conf.js +118 -0
- package/web/files.css +206 -1
- package/web/files.html +10 -0
- package/web/files.js +533 -75
- package/web/fuzzy.js +73 -21
- package/web/index.html +109 -12
- package/web/json.js +145 -0
- package/web/list.css +536 -0
- package/web/list.js +1015 -0
- package/web/mentions.js +27 -9
- package/web/recency.js +10 -10
- package/web/units.js +1 -1
- package/web/usage.css +169 -0
- package/web/usage.html +78 -0
- package/web/usage.js +447 -0
- package/web/view.js +22 -3
- package/web/whatsnew.css +211 -0
- package/web/whatsnew.html +49 -0
- 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
|
|
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
|
|
42
|
-
*
|
|
43
|
-
* the first time a monitor is unplugged every window would
|
|
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
|
|
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 * ${
|
|
57
|
-
set h to round (vh * ${
|
|
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 = `${
|
|
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
|
|
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
|
|
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
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
|
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',
|
|
111
|
-
return { touched:
|
|
112
|
-
} catch {
|
|
113
|
-
return { touched: 0
|
|
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
|
-
*
|
|
119
|
-
*
|
|
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
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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 =
|
|
135
|
-
return
|
|
136
|
-
|
|
137
|
-
(
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
147
|
-
* the screen changes underneath them — a monitor plugged in or unplugged, a
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(),
|
package/platform/linux/index.js
CHANGED
|
@@ -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
|
: {
|
package/platform/linux/shell.js
CHANGED
|
@@ -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() })
|
package/platform/linux/tray.py
CHANGED
|
@@ -152,7 +152,7 @@ def _forget(notification):
|
|
|
152
152
|
_shown.remove(notification)
|
|
153
153
|
|
|
154
154
|
|
|
155
|
-
def notify(
|
|
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/
|
|
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
|
|
216
|
-
now = {(
|
|
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
|
|
219
|
-
if (
|
|
220
|
-
notify(
|
|
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
|
|
235
|
-
label = f"{LABELS.get(
|
|
236
|
-
self.add_item(label, lambda _w, c=
|
|
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
|
-
|
|
15
|
-
|
|
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),
|