@galda/cli 0.10.49 → 0.10.50

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 (2) hide show
  1. package/app/index.html +75 -19
  2. package/package.json +1 -1
package/app/index.html CHANGED
@@ -472,7 +472,17 @@
472
472
  scoped here, since every other .qsub stays a one-liner. */
473
473
  .replynote{white-space:normal;overflow:visible;margin-top:14px;padding-top:12px;border-top:1px solid var(--hair)}
474
474
  .replynote .qchips{margin-top:6px}
475
- .replynote .txr.note .m{color:var(--ink3)}
475
+ /* The .txr You/Claude rows are styled by `.redesign .txr` — which the See-all card
476
+ (.sa-item.redesign) provides, but the goal-detail overlay and review dialog do NOT.
477
+ There the same note rendered as unstyled run-in black text ("Youこんにちは" with no
478
+ gap/weight — Masa 2026-07-24 "弾"). Scope the row layout to .replynote too so the
479
+ reply thread reads the same wherever its box lives. */
480
+ .replynote .txr{display:flex;gap:12px;padding:5px 0;font-size:13px;line-height:1.55;align-items:baseline}
481
+ .replynote .txr .w{flex:0 0 46px;font-size:11px;color:var(--ink3)}
482
+ .replynote .txr.you .w,.replynote .txr.cl .w{color:var(--ink);font-weight:600}
483
+ .replynote .txr .m{flex:1;min-width:0;color:var(--ink2)}
484
+ .replynote .txr.cl .m{color:var(--ink)}
485
+ .replynote .txr.note .m{font-family:var(--mono);font-size:12px;color:var(--ink3)}
476
486
  .ob-unsent{color:rgb(var(--danger-rgb));cursor:pointer}
477
487
  [data-oresend]{cursor:pointer}
478
488
  .chip2{font-family:var(--mono);font-size:9.5px;letter-spacing:.05em;padding:2px 7px;border-radius:6px;border:1px solid var(--hair2);color:var(--ink3);flex:0 0 auto}
@@ -2283,7 +2293,7 @@
2283
2293
  #fsRoot .trow .st{width:13px;height:13px;border-radius:50%;border:1.5px solid var(--hair2);flex:0 0 13px;position:relative;top:1px} /* top:1px = optical nudge so the whole status column (To Do/Doing/Failed/Done) centres on the row text, not the line-box (Masa 2026-07-24, board=C) */
2284
2294
  /* Doing marker = a broken green ring that spins (pattern B · flagship #316). Running only —
2285
2295
  .bad (failed) / .int (paused) stay static: no dead motion (DESIGN-RULES §5.5). Reuses @keyframes spin. */
2286
- #fsRoot .trow .st.run{border-color:var(--green);border-top-color:transparent;background:none;box-shadow:0 0 6px color-mix(in srgb,var(--green) 40%,transparent);animation:spin 1.8s linear infinite}
2296
+ #fsRoot .trow .st.run{border-color:var(--green);border-top-color:transparent;background:none;box-shadow:0 0 6px color-mix(in srgb,var(--green) 40%,transparent);animation:spin 1.8s linear infinite;animation-delay:var(--spd,0ms)} /* --spd = wall-clock phase (set in render()) so a re-created board marker doesn't restart the spin at 0deg */
2287
2297
  #fsRoot .trow .st.run.bad{border-color:var(--danger);background:radial-gradient(circle,rgba(var(--danger-rgb),.42),transparent 70%);animation:none;box-shadow:none}
2288
2298
  #fsRoot .trow .st.run.int{border-color:var(--amber);background:radial-gradient(circle,rgba(var(--amber-rgb),.4),transparent 70%);animation:none;box-shadow:none}
2289
2299
  @media (prefers-reduced-motion:reduce){#fsRoot .trow .st.run{animation:none}}
@@ -6195,6 +6205,11 @@ function renderFlagship(){
6195
6205
  if (state.layout !== 'flagship') return;
6196
6206
  const root = $('fsRoot'); if (!root) return;
6197
6207
  root.dataset.rv = String(fsUI.rv);
6208
+ // Wall-clock phase for the spinning markers: any ring re-created this render (the board
6209
+ // re-renders often on progress %) starts at the angle a continuous one would be at now,
6210
+ // instead of snapping back to 0deg — no "jump". The rail ring is guarded separately and
6211
+ // doesn't read --spd, so changing it here can't disturb the persistent rail ring. (Masa 2026-07-24)
6212
+ root.style.setProperty('--spd', (-(Date.now() % 1800)) + 'ms');
6198
6213
  renderFsRail(); renderFsFeed(); renderFsLane();
6199
6214
  }
6200
6215
  function renderFsRail(){
@@ -6269,6 +6284,14 @@ function fsActiveContext(){
6269
6284
  // Per-project last non-empty center feed — the whiteout guard (see below): keeps
6270
6285
  // what the user was looking at when a render pass momentarily produces no lines.
6271
6286
  const _fsLastFeed = {};
6287
+ // H-CENTERLANE decision 1: pure merge-sort of timed HTML blocks (the focused task's
6288
+ // activity block + each chat turn) so the center lane renders in the order things
6289
+ // actually happened. Stable sort (spec-guaranteed since ES2019) keeps same-ts blocks
6290
+ // in their original relative order. Pure/no DOM — covered directly in
6291
+ // engine/test/center-lane-timeline.test.mjs.
6292
+ function fsMergeTimelineBlocks(blocks){
6293
+ return [...blocks].sort((a, b) => a.ts - b.ts).map((b) => b.html).join('');
6294
+ }
6272
6295
  function renderFsFeed(){
6273
6296
  if (state.layout !== 'flagship') return;
6274
6297
  const feed = $('fsFeed'); if (!feed) return;
@@ -6313,7 +6336,12 @@ function renderFsFeed(){
6313
6336
  } else { nd.hidden = true; nd.innerHTML = ''; }
6314
6337
  }
6315
6338
  let html = '';
6339
+ // H-CENTERLANE decision 1: the focused task's activity block is anchored at its
6340
+ // startedAt so it can be merge-sorted against chat turns below (state.act carries
6341
+ // no per-line timestamp, so the whole block — not each line — is the sort unit).
6342
+ let focusTs = 0;
6316
6343
  if (focus) {
6344
+ focusTs = focus.startedAt ? Date.parse(focus.startedAt) : 0;
6317
6345
  const lines = state.act[focus.id] ?? [];
6318
6346
  const lineLimit = fsUI.logOpen.has(focus.id) || focus.status === 'running' || ['running', 'partial'].includes(goal?.status) ? 1000 : 80;
6319
6347
  html += lines.slice(-lineLimit).map(fsFeedRow).join('');
@@ -6380,29 +6408,39 @@ function renderFsFeed(){
6380
6408
  // whole-feed re-render (SSE act events) can't drop them. Order per turn:
6381
6409
  // You → (mark, if it became a task) and, for a reply, speaker line → reply.
6382
6410
  const turns = (state.chatTurns[state.active] || []).slice(-40);
6383
- let chatHtml = '';
6411
+ // H-CENTERLANE decision 1: each turn becomes its own timed block (ts, html) so it
6412
+ // can be merge-sorted against the focused task's activity block below, instead of
6413
+ // always rendering as one chat block stapled after the task feed regardless of
6414
+ // when either actually happened.
6415
+ const chatBlocks = [];
6384
6416
  for (const turn of turns) {
6417
+ let t = '';
6385
6418
  if (turn.role === 'you') {
6386
- chatHtml += `<div class="cc-you"><span class="cy-n">You</span><div class="cy-t">${esc(turn.text)}</div></div>`;
6419
+ t += `<div class="cc-you"><span class="cy-n">You</span><div class="cy-t">${esc(turn.text)}</div></div>`;
6387
6420
  if (turn.markGoalId != null && !turn.undone) {
6388
6421
  const mg = state.goals.find((x) => x.id === turn.markGoalId);
6389
6422
  const n = mg ? goalReviewNumber({ goal: mg, tasks: state.tasks }) : turn.markGoalId;
6390
- chatHtml += `<div class="cc-mark"><span class="cm-ar">→</span><span class="cm-t">Added to To Do</span><span class="cm-no">#${n}</span><button class="cm-undo" type="button" data-ccundo="${turn.markGoalId}">Undo</button></div>`;
6423
+ t += `<div class="cc-mark"><span class="cm-ar">→</span><span class="cm-t">Added to To Do</span><span class="cm-no">#${n}</span><button class="cm-undo" type="button" data-ccundo="${turn.markGoalId}">Undo</button></div>`;
6391
6424
  }
6392
6425
  } else { // Claude Code reply — speaker line first (You → speaker → reply), then the text
6393
6426
  const app = connectedAppMeta(turn.agent);
6394
- chatHtml += `<div class="cc-who">${app.icon}<span class="cs-n">${esc(app.label)}</span></div>`;
6427
+ t += `<div class="cc-who">${app.icon}<span class="cs-n">${esc(app.label)}</span></div>`;
6395
6428
  const shown = esc((turn.text || '').slice(0, turn.revealed || 0));
6396
- chatHtml += turn.done
6429
+ t += turn.done
6397
6430
  ? `<div class="cc-say">${shown}</div>`
6398
6431
  : `<div class="cc-say"><span id="ccTyping">${shown}</span><span class="cc-caret"></span></div>`;
6399
6432
  }
6433
+ chatBlocks.push({ ts: turn.ts ?? 0, html: t });
6400
6434
  }
6401
6435
  const pgoals = state.goals.filter((g) => g.projectId === state.active);
6402
6436
  const ptasks = state.tasks.filter((t) => t.projectId === state.active);
6403
6437
  const hasWork = pgoals.length > 0 || ptasks.length > 0 || turns.length > 0;
6404
- // task feed (with its own speaker line) conversation turns → question (bottom)
6405
- let out = html + chatHtml + askHtml;
6438
+ // H-CENTERLANE decision 1: interleave the focused task's activity block with the
6439
+ // chat turns in the order they actually happened (stable sort keeps same-ts items
6440
+ // in their original relative order); the question card(s) stay pinned at the very
6441
+ // end (H18/H19 — a pending question must never be lost mid-scroll).
6442
+ const timelineBlocks = html ? [{ ts: focusTs, html }, ...chatBlocks] : chatBlocks;
6443
+ let out = fsMergeTimelineBlocks(timelineBlocks) + askHtml;
6406
6444
  if (out) _fsLastFeed[state.active] = out;
6407
6445
  else if (hasWork) out = _fsLastFeed[state.active] || `<div class="cc-c">⎿ ${esc(running.length ? 'working…' : 'no active task')}</div>`;
6408
6446
  $('fsRoot').classList.toggle('clawdhero', !hasWork);
@@ -6819,7 +6857,10 @@ function fsBucketTodo(){
6819
6857
  needsInput: pgoals.filter((g) => g.status === 'needsInput'), // a clarifying question is waiting on the user (board's "Needs you")
6820
6858
  reviewGoals: pgoals.filter((g) => g.status === 'review'),
6821
6859
  queued: list.filter((t) => t.status === 'queued' && !t.reply).sort(byQueue),
6822
- doneAll: list.filter((t) => ['done', 'skipped'].includes(t.status) && !closedGoalIds.has(t.goalId)).sort((a, b) => b.num - a.num),
6860
+ // A reply task's completion is already visible inline in the center-lane timeline
6861
+ // (H-CENTERLANE decision 3) — showing it again as an independent Done row is the
6862
+ // same event twice. `queued` already guards `!t.reply` above; mirror it here.
6863
+ doneAll: list.filter((t) => ['done', 'skipped'].includes(t.status) && !closedGoalIds.has(t.goalId) && !t.reply).sort((a, b) => b.num - a.num),
6823
6864
  later: pgoals.filter((g) => g.status === 'pending'),
6824
6865
  rejected: pgoals.filter((g) => g.status === 'rejected'), // 07-20c: Reject parks goals here (own fold, like Done)
6825
6866
  };
@@ -6946,7 +6987,7 @@ function renderFsTodo(){
6946
6987
  <button class="qadd" data-fsnew="1" title="New goal"><span class="paplus"></span>New</button>
6947
6988
  </div>
6948
6989
  ${doneAll.length ? `<div class="doneline${fsUI.doneOpen ? ' open' : ''}" id="fsDoneLine">✓ Done<span class="num">${doneAll.length}</span><span class="fold">▾</span></div>
6949
- <div id="fsDonelist"${fsUI.doneOpen ? '' : ' hidden'}>${doneAll.slice(0, 3).map((t) => `<div class="trow up dim done"><span class="st ok"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span></div>`).join('')}</div>` : ''}`;
6990
+ <div id="fsDonelist"${fsUI.doneOpen ? '' : ' hidden'}>${doneAll.map((t) => `<div class="trow up dim done"><span class="st ok"></span>${noSpan(t.goalId)}<span class="tt">${tt(t.title)}</span></div>`).join('')}</div>` : ''}`;
6950
6991
  el.classList.toggle('folded', !!fsUI.todoFold);
6951
6992
  $('fsTodoHd').onclick = () => { fsUI.todoFold = !fsUI.todoFold; el.classList.toggle('folded', fsUI.todoFold); };
6952
6993
  const qadd = el.querySelector('.qadd[data-fsnew]'); // + New → focus the composer (the real create path)
@@ -8218,7 +8259,7 @@ async function postOutboxItem(item){
8218
8259
  // .peraanswer bar. Never land()/upsert(): a kind:'chat' response has no id.
8219
8260
  outbox.remove(item.oid);
8220
8261
  if (state.layout === 'flagship') {
8221
- const turn = { role: 'clawd', text: String(goal.reply || ''), revealed: 0, done: false, agent: item.agent };
8262
+ const turn = { role: 'clawd', text: String(goal.reply || ''), revealed: 0, done: false, agent: item.agent, ts: Date.now() };
8222
8263
  (state.chatTurns[item.projectId] ||= []).push(turn);
8223
8264
  renderFsFeed();
8224
8265
  typeChatTurn(turn);
@@ -9995,7 +10036,7 @@ async function argItems(cmd){
9995
10036
  // Read the raw input → decide what the palette shows (null = closed).
9996
10037
  function paletteState(val){
9997
10038
  if (val[0] !== '/') return null;
9998
- const m = val.match(/^\/(\w*)(\s)?([\s\S]*)$/);
10039
+ const m = val.match(/^\/([\w-]*)(\s)?([\s\S]*)$/); // allow '-' so hyphenated skill names filter (english-practice)
9999
10040
  if (!m) return null;
10000
10041
  const word = m[1], hadSpace = m[2] != null, rest = m[3];
10001
10042
  if (!hadSpace) return { level: 'top', query: word }; // still typing the command name
@@ -10018,11 +10059,18 @@ function renderPalette(){
10018
10059
  if (!p || !el) return;
10019
10060
  const head = p.level === 'top' ? 'Commands' : '/' + p.cmd;
10020
10061
  if (!p.items.length) { el.innerHTML = `<div class="sphead">${esc(head)}</div><div class="empty">No match</div>`; el.classList.add('show'); return; }
10021
- el.innerHTML = `<div class="sphead">${esc(head)}</div>` + p.items.map((it, i) => {
10022
- const name = p.level === 'top' ? '/' + it.cmd + (it.arg ? ' …' : '') : it.v;
10023
- const desc = p.level === 'top' ? it.desc : (it.d || '');
10024
- return `<button type="button" class="spopt${i === p.sel ? ' on' : ''}" data-i="${i}" role="option"><span class="spn">${esc(name)}</span><span class="spd">${esc(desc)}</span></button>`;
10025
- }).join('');
10062
+ let html = '', lastGroup = null;
10063
+ p.items.forEach((it, i) => {
10064
+ // group headers: top-level splits Commands vs Skills; arg-level shows the "/cmd" head once.
10065
+ if (p.level === 'top') {
10066
+ const group = it.skill ? 'Skills' : 'Commands';
10067
+ if (group !== lastGroup) { html += `<div class="sphead">${group}</div>`; lastGroup = group; }
10068
+ } else if (i === 0) { html += `<div class="sphead">${esc('/' + p.cmd)}</div>`; }
10069
+ const name = p.level === 'top' ? (it.skill ? '/' + it.skill : '/' + it.cmd + (it.arg ? ' …' : '')) : it.v;
10070
+ const desc = p.level === 'top' ? (it.desc || '') : (it.d || '');
10071
+ html += `<button type="button" class="spopt${i === p.sel ? ' on' : ''}" data-i="${i}" role="option"><span class="spn">${esc(name)}</span><span class="spd">${esc(desc)}</span></button>`;
10072
+ });
10073
+ el.innerHTML = html;
10026
10074
  el.classList.add('show');
10027
10075
  for (const b of el.querySelectorAll('[data-i]')) b.onmousedown = (e) => { e.preventDefault(); state.palette.sel = Number(b.dataset.i); acceptPalette(); };
10028
10076
  }
@@ -10034,6 +10082,13 @@ async function refreshPalette(){
10034
10082
  if (info.level === 'top') {
10035
10083
  const q = info.query.toLowerCase();
10036
10084
  items = SLASH_CMDS.filter((s) => !q || s.cmd.startsWith(q));
10085
+ // Once the user types after "/", also offer their registered skills (type-to-filter, so a
10086
+ // bare "/" stays a short command list). Selecting one runs the goal with that skill first.
10087
+ if (q) {
10088
+ const skills = (await loadSkills()).filter((s) => s.name.toLowerCase().includes(q));
10089
+ if ($('input').value !== val) return; // input changed while awaiting skills
10090
+ items = items.concat(skills.map((s) => ({ skill: s.name, desc: `${s.kind || 'skill'}${s.description ? ' · ' + s.description : ''}`.trim() })));
10091
+ }
10037
10092
  } else {
10038
10093
  const all = await argItems(info.cmd);
10039
10094
  if ($('input').value !== val) return; // input changed while awaiting skills
@@ -10054,7 +10109,8 @@ function acceptPalette(){
10054
10109
  const p = state.palette; if (!p) return false;
10055
10110
  const item = p.items[p.sel]; if (!item) return true;
10056
10111
  if (p.level === 'top') {
10057
- if (item.arg) { $('input').value = '/' + item.cmd + ' '; refreshPalette(); }
10112
+ if (item.skill) { applySlash('skill', item.skill); $('input').value = $('input').value.replace(/^\s*\/\S*\s*/, ''); closePalette(); } // hyphen-safe strip of the "/skillname" token
10113
+ else if (item.arg) { $('input').value = '/' + item.cmd + ' '; refreshPalette(); }
10058
10114
  else { applySlash(item.cmd, null); $('input').value = stripLeadingCmd($('input').value); closePalette(); }
10059
10115
  } else {
10060
10116
  applySlash(p.cmd, item.v);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.49",
3
+ "version": "0.10.50",
4
4
  "type": "module",
5
5
  "description": "Galda - hand off work to Claude Code or Codex, get proof back. Runs on your existing subscription, no extra API cost.",
6
6
  "scripts": {