@lamemind/loom-deck 0.26.0 → 0.27.1
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/dist/cli.js +42 -7
- package/package.json +1 -1
- package/scripts/deck-run +49 -1
package/dist/cli.js
CHANGED
|
@@ -157,8 +157,19 @@ function spawnDeck(id, cwd, sessionId, kind) {
|
|
|
157
157
|
// task). Spot → `--no-task --resume`: resume nudo, solo label progetto. Nessun
|
|
158
158
|
// prompt iniziale in entrambi i casi: riprendere una conversazione significa
|
|
159
159
|
// continuarla, non iniettarle un messaggio (lo salta deck-run).
|
|
160
|
-
|
|
160
|
+
//
|
|
161
|
+
// T64 — la NOTA della conversazione (se c'è) viaggia nel titolo della tab. Più
|
|
162
|
+
// sessioni sulla stessa task hanno oggi titoli identici (`label · T81`): la nota
|
|
163
|
+
// è già ciò con cui l'utente le distingue in lista, quindi è anche ciò che
|
|
164
|
+
// distingue le tab. La passa il DECK e non la legge deck-run perché la nota vive
|
|
165
|
+
// nel sidecar `session-tasks.jsonl`, che deck-run non tocca (legge solo
|
|
166
|
+
// `.claude/loom-works.json`): tenerlo così evita di dare al primitive un secondo
|
|
167
|
+
// file da conoscere. Il titolo si congela qui — `claude --name` lo setta una
|
|
168
|
+
// volta sola, quindi una nota cambiata DOPO non ri-titola la tab già aperta.
|
|
169
|
+
function spawnDeckResume(taskId, cwd, sessionId, note) {
|
|
161
170
|
const args = taskId ? [taskId, '--resume', sessionId] : ['--no-task', '--resume', sessionId];
|
|
171
|
+
if (note)
|
|
172
|
+
args.push('--title-note', note);
|
|
162
173
|
const child = spawnOut(DECK_RUN, args, { cwd, detached: true, stdio: 'ignore' });
|
|
163
174
|
child.unref();
|
|
164
175
|
return child;
|
|
@@ -171,6 +182,10 @@ function spawnDeckResume(taskId, cwd, sessionId) {
|
|
|
171
182
|
// conoscerlo prima che la sessione esista, e senza conoscerlo non si possono
|
|
172
183
|
// scrivere né il binding task né il record di lineage (il transcript del fork
|
|
173
184
|
// non nomina da nessuna parte la sessione d'origine).
|
|
185
|
+
// Nessun `--title-note` (T64): il ramo nasce con un sessionId proprio e SENZA
|
|
186
|
+
// nota nel sidecar — ereditare quella dell'origine metterebbe nel titolo una
|
|
187
|
+
// maniglia che nella lista non compare, cioè una promessa falsa. Il fork si
|
|
188
|
+
// distingue col suo marcatore, `· fork`.
|
|
174
189
|
function spawnDeckFork(taskId, cwd, originId, newId) {
|
|
175
190
|
const args = [
|
|
176
191
|
...(taskId ? [taskId] : ['--no-task']),
|
|
@@ -665,7 +680,11 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
665
680
|
for (const r of sessionRows) {
|
|
666
681
|
if (r.kind === 'separator')
|
|
667
682
|
continue;
|
|
668
|
-
|
|
683
|
+
// La colonna serve dove l'appartenenza NON è già scritta altrove sullo
|
|
684
|
+
// schermo: nella vista "tutte" (ogni riga ha un binding proprio) e su
|
|
685
|
+
// OGNI riga pinnata, in qualunque vista — una pinnata è sganciata dal
|
|
686
|
+
// parent selezionato, quindi l'header del pane non parla per lei.
|
|
687
|
+
if (isAll || r.kind === 'pinned') {
|
|
669
688
|
const b = bindings.get(r.sessionId);
|
|
670
689
|
if (b)
|
|
671
690
|
task = Math.max(task, termWidth(b));
|
|
@@ -1016,7 +1035,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1016
1035
|
}
|
|
1017
1036
|
if (row.kind === 'session') {
|
|
1018
1037
|
const bound = bindings.get(row.session.sessionId) ?? null;
|
|
1019
|
-
const child = spawnDeckResume(bound, cwd, row.session.sessionId);
|
|
1038
|
+
const child = spawnDeckResume(bound, cwd, row.session.sessionId, sessionNotes.get(row.session.sessionId));
|
|
1020
1039
|
child.on('error', () => setNote(`⚠ resume fallito (${DECK_RUN})`));
|
|
1021
1040
|
setNote(`⏎ resume ${row.session.sessionId.slice(0, 8)} → tab CC${bound ? ` (${bound})` : ' (spot)'}`);
|
|
1022
1041
|
return;
|
|
@@ -1466,7 +1485,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1466
1485
|
}
|
|
1467
1486
|
else {
|
|
1468
1487
|
const bound = bindings.get(s.sessionId) ?? null;
|
|
1469
|
-
const child = spawnDeckResume(bound, cwd, s.sessionId);
|
|
1488
|
+
const child = spawnDeckResume(bound, cwd, s.sessionId, sessionNotes.get(s.sessionId));
|
|
1470
1489
|
child.on('error', () => setNote(`⚠ resume fallito (${DECK_RUN})`));
|
|
1471
1490
|
setNote(`⏎ resume ${s.sessionId.slice(0, 8)} → tab CC${bound ? ` (${bound})` : ' (spot)'}`);
|
|
1472
1491
|
}
|
|
@@ -2044,8 +2063,17 @@ function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows,
|
|
|
2044
2063
|
// riga oltre il bordo, e a ripararla arrivava `cli-truncate` (che
|
|
2045
2064
|
// sfora di una colonna per emoji e mangia il bordo stesso).
|
|
2046
2065
|
const staleNote = sessionNotes.get(row.sessionId);
|
|
2047
|
-
|
|
2048
|
-
|
|
2066
|
+
// La riga stale è libera (niente colonne: non ha né titolo né
|
|
2067
|
+
// data), ma il binding va detto lo stesso — è una pinnata, quindi
|
|
2068
|
+
// l'header del pane non ne dice l'appartenenza.
|
|
2069
|
+
const staleTask = bindings.get(row.sessionId) ?? null;
|
|
2070
|
+
const staleW = Math.max(0, paneTextWidth(columns) -
|
|
2071
|
+
(2 /* caret */ +
|
|
2072
|
+
termWidth(`${WARN} pin stale `) +
|
|
2073
|
+
SID_CHARS +
|
|
2074
|
+
(staleTask ? termWidth(staleTask) + 1 : 0) +
|
|
2075
|
+
3 /* spazio + caporali */));
|
|
2076
|
+
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, dimColor: true, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, _jsx(Text, { color: "yellow", children: WARN }), " pin stale", ' ', _jsx(Text, { dimColor: true, children: row.sessionId.slice(0, SID_CHARS) }), staleTask ? _jsxs(Text, { color: "green", children: [" ", staleTask] }) : null, staleNote ? _jsxs(Text, { color: "yellow", children: [" \u00AB", cut(staleNote, staleW), "\u00BB"] }) : null] }, row.sessionId));
|
|
2049
2077
|
}
|
|
2050
2078
|
const s = row.session; // non-stale → session presente
|
|
2051
2079
|
const isPinnedRow = row.kind === 'pinned';
|
|
@@ -2061,6 +2089,13 @@ function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows,
|
|
|
2061
2089
|
// task accanto, che esiste solo in questa vista.
|
|
2062
2090
|
const bound = bindings.get(s.sessionId) ?? null;
|
|
2063
2091
|
const linked = isAll ? Boolean(bound) : !isSpot;
|
|
2092
|
+
// Stesso motivo per cui la colonna esiste: una pinnata resta in lista
|
|
2093
|
+
// qualunque sia il parent selezionato, quindi l'header non ne dice
|
|
2094
|
+
// l'appartenenza e la cella va riempita anche fuori dalla vista
|
|
2095
|
+
// "tutte". Sulle contestuali, dove l'header parla già, resta vuota —
|
|
2096
|
+
// ma la cella è comunque larga `taskW`, o le colonne a destra
|
|
2097
|
+
// slitterebbero riga per riga.
|
|
2098
|
+
const taskCell = isAll || isPinnedRow ? (bound ?? TASK_EMPTY) : '';
|
|
2064
2099
|
// T60 — colonne VERE: ogni cella fissa è larga esattamente quanto
|
|
2065
2100
|
// dichiara, riempita di spazi con `pad` (che misura in colonne, non in
|
|
2066
2101
|
// caratteri). Il marker va portato a 2 anche quando è `○`, largo 1:
|
|
@@ -2094,7 +2129,7 @@ function SessionsPane({ parentLabel, isSpot, isAll, bindings, taskW, ageW, rows,
|
|
|
2094
2129
|
const used = (label.note ? termWidth(label.note) + 2 : 0) +
|
|
2095
2130
|
(label.note && label.rest ? 1 : 0) +
|
|
2096
2131
|
termWidth(label.rest);
|
|
2097
|
-
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isPinnedRow ? (_jsx(Text, { color: "yellow", children: pad('📌', 2) })) : linked ? (_jsx(Text, { color: "green", children: pad('🔗', 2) })) : (_jsx(Text, { dimColor: true, children: pad('○', 2) })), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, SID_CHARS) }), ' ', taskW > 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: bound ? 'green' : undefined, dimColor: !bound, children: pad(
|
|
2132
|
+
return (_jsxs(Text, { inverse: sel && focused, bold: sel && !focused, wrap: "truncate-end", children: [sel ? CARET : CARET_OFF, isPinnedRow ? (_jsx(Text, { color: "yellow", children: pad('📌', 2) })) : linked ? (_jsx(Text, { color: "green", children: pad('🔗', 2) })) : (_jsx(Text, { dimColor: true, children: pad('○', 2) })), ' ', _jsx(Text, { color: "cyan", children: s.sessionId.slice(0, SID_CHARS) }), ' ', taskW > 0 ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: bound && taskCell ? 'green' : undefined, dimColor: !bound, children: pad(taskCell, taskW) }), ' '] })) : null, forkMark ? _jsx(Text, { color: "magenta", children: forkMark }) : null, label.note ? (_jsxs(Text, { color: "yellow", bold: true, children: ["\u00AB", label.note, "\u00BB"] })) : null, label.note && label.rest ? ' ' : null, label.rest ? _jsx(Text, { dimColor: Boolean(label.note), children: label.rest }) : null, ' '.repeat(Math.max(0, inner - used)), ' ', _jsx(Text, { dimColor: true, children: pad(age, ageW, 'right') })] }, s.sessionId));
|
|
2098
2133
|
})), detail ? (_jsx(SessionDetailPane, { s: detail, firstLines: firstLines, lastLines: lastLines, columns: columns, origin: forkOf.get(detail.sessionId) ?? null, note: sessionNotes.get(detail.sessionId) ?? '' })) : null] }));
|
|
2099
2134
|
}
|
|
2100
2135
|
// T49 — detail pane della sessione selezionata (hover), gemello del DetailPane
|
package/package.json
CHANGED
package/scripts/deck-run
CHANGED
|
@@ -49,6 +49,7 @@ SESSION_ID=""
|
|
|
49
49
|
RESUME_ID=""
|
|
50
50
|
NO_TASK=0
|
|
51
51
|
FORK=0
|
|
52
|
+
TITLE_NOTE=""
|
|
52
53
|
# Vuoto = flag non passato (≠ un default già risolto): serve a distinguere
|
|
53
54
|
# "kind implicito" da "kind chiesto", perché con --no-task il primo è legittimo
|
|
54
55
|
# (nessun prompt, come sempre) e il secondo è un errore d'uso.
|
|
@@ -76,8 +77,17 @@ PROMPT_KIND=""
|
|
|
76
77
|
# dentro apici singoli in `bash -lc`, quindi il testo va tenuto in un posto solo
|
|
77
78
|
# e verificato una volta, invece di spostare il rischio di quoting su ogni
|
|
78
79
|
# chiamante. Assente → `recap`, così ogni invocazione preesistente è invariata.
|
|
80
|
+
# --title-note <testo> (T64): appende la nota della conversazione al titolo della
|
|
81
|
+
# tab. È l'UNICO ingresso di testo libero nel titolo — gli altri componenti
|
|
82
|
+
# (emoji/name dal file committato, TaskID) sono controllati — quindi il testo non
|
|
83
|
+
# si quota, si RIDUCE a un alfabeto sicuro (vedi _sane_note): tutto ciò che non
|
|
84
|
+
# ci rientra sparisce, apici inclusi.
|
|
79
85
|
while [[ $# -gt 0 ]]; do
|
|
80
86
|
case "$1" in
|
|
87
|
+
--title-note)
|
|
88
|
+
TITLE_NOTE="${2:-}"; shift 2 ;;
|
|
89
|
+
--title-note=*)
|
|
90
|
+
TITLE_NOTE="${1#*=}"; shift ;;
|
|
81
91
|
--prompt-kind)
|
|
82
92
|
PROMPT_KIND="${2:-}"; shift 2 ;;
|
|
83
93
|
--prompt-kind=*)
|
|
@@ -112,7 +122,10 @@ USAGE="uso: deck-run <TaskID> [--prompt-kind <kind>] [--session-id <uuid>]
|
|
|
112
122
|
none nessun prompt — sessione aperta sulla task, a mani nude
|
|
113
123
|
recap recap stato task <TaskID>
|
|
114
124
|
preflight /loom-works:preflight-task <TaskID>
|
|
115
|
-
run /loom-works:run-task <TaskID> (run-doc sulle task D)
|
|
125
|
+
run /loom-works:run-task <TaskID> (run-doc sulle task D)
|
|
126
|
+
|
|
127
|
+
--title-note nota della conversazione, appesa al titolo tab come «nota»
|
|
128
|
+
(ridotta a lettere/cifre/spazi/-/_, cap 60 char)"
|
|
116
129
|
|
|
117
130
|
if [[ $NO_TASK -eq 1 && -n "$TASK" ]]; then
|
|
118
131
|
echo "--no-task e <TaskID> sono mutuamente esclusivi" >&2
|
|
@@ -167,6 +180,29 @@ WORKDIR="${LOOM_DECK_WORKDIR:-$PWD}"
|
|
|
167
180
|
# suffisso "· <task>" per restare distinguibili fra più tab claude (il suffisso
|
|
168
181
|
# non rompe il match: è .includes, non equals — stesso pattern del "· deck").
|
|
169
182
|
# Fallback su "cc <task>" se manca il file o jq (progetto non loom-registered).
|
|
183
|
+
|
|
184
|
+
# Riduzione della nota a un alfabeto sicuro (T64). Whitelist, non blacklist: il
|
|
185
|
+
# titolo finisce dentro apici singoli in `bash -lc "claude --name '…'"`, e la
|
|
186
|
+
# nota è testo digitato a mano — enumerare i caratteri pericolosi significa
|
|
187
|
+
# sbagliarne uno, enumerare quelli ammessi no. Restano lettere, cifre, accentate
|
|
188
|
+
# italiane, spazio, trattino, underscore; tutto il resto (apici, backslash,
|
|
189
|
+
# `$`, backtick, `;`, emoji…) cade.
|
|
190
|
+
# Il cap a 60 CHARACTER è largo di proposito: una tab che sfora tronca a destra
|
|
191
|
+
# da sola, quindi il limite serve solo a non spingere fuori vista la parte del
|
|
192
|
+
# titolo che compass matcha, non a far stare la nota nella tab.
|
|
193
|
+
_sane_note() { # <raw> → nota ridotta, spazi collassati, cap 60 char
|
|
194
|
+
# LC_ALL locale alla funzione: serve a bash per tagliare a 60 CARATTERI e non
|
|
195
|
+
# a 60 byte (a byte, un cap che cade a metà di una `à` lascia UTF-8 rotto nel
|
|
196
|
+
# titolo). L'assegnazione rifà setlocale, l'uscita dalla funzione lo ripristina.
|
|
197
|
+
local LC_ALL=C.UTF-8
|
|
198
|
+
local s
|
|
199
|
+
s="$(printf '%s' "$1" \
|
|
200
|
+
| sed 's/[^A-Za-z0-9 _àèéìòùÀÈÉÌÒÙ-]//g' \
|
|
201
|
+
| tr -s ' ')"
|
|
202
|
+
s="${s# }"; s="${s% }"
|
|
203
|
+
printf '%s' "${s:0:60}"
|
|
204
|
+
}
|
|
205
|
+
|
|
170
206
|
_find_project_root() { # <start-dir> → dir con .claude/loom-works.json, o vuoto
|
|
171
207
|
local d="${1%/}"
|
|
172
208
|
while [[ -n "$d" && "$d" != "/" ]]; do
|
|
@@ -198,6 +234,18 @@ if [[ -n "$_cfg" ]]; then
|
|
|
198
234
|
if [[ $NO_TASK -eq 1 ]]; then TITLE="${_label}"; else TITLE="${_label} · ${TASK}"; fi
|
|
199
235
|
fi
|
|
200
236
|
fi
|
|
237
|
+
# Suffisso nota (T64): distingue fra loro le tab di più conversazioni sulla
|
|
238
|
+
# STESSA task, che altrimenti condividono un titolo identico (`label · T81`) —
|
|
239
|
+
# la nota è già la maniglia con cui l'utente le distingue in lista, e le stesse
|
|
240
|
+
# `«…»` della lista la rendono riconoscibile a colpo d'occhio anche nella tab
|
|
241
|
+
# bar. In coda per la solita ragione degli altri suffissi: il match compass è
|
|
242
|
+
# `.includes(label)`, la label deve restare intatta in TESTA.
|
|
243
|
+
# Nota ridotta a vuoto (era tutta emoji/punteggiatura) → nessun suffisso, non
|
|
244
|
+
# `«»` a vuoto.
|
|
245
|
+
if [[ -n "$TITLE_NOTE" ]]; then
|
|
246
|
+
_note="$(_sane_note "$TITLE_NOTE")"
|
|
247
|
+
[[ -n "$_note" ]] && TITLE="${TITLE} «${_note}»"
|
|
248
|
+
fi
|
|
201
249
|
# Suffisso fork (T28): un ramo eredita task e label dell'origine, quindi senza
|
|
202
250
|
# marcatore le due tab risulterebbero omonime nella stessa window. Suffisso e
|
|
203
251
|
# non prefisso perché il match compass è `.includes(label)` e la label deve
|