@zgeoff/atc 2.28.0 → 2.30.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.
@@ -124,10 +124,14 @@ type SourceStepAction =
124
124
  * optional first prompt. The sources are the ones the daemon offers, in
125
125
  * its order, and Tab cycles them; a daemon that offers none gets the local
126
126
  * directory flow alone. A source of directories is followed by the
127
- * execution target when the daemon has more than one. A source of git
127
+ * execution target when more than one target can run it. A source of git
128
128
  * repositories opens the target first, then its candidates, the ref, and a
129
129
  * confirm screen with the destination the checkout lands in, so every
130
130
  * request about the repository is made for the target the spawn runs on.
131
+ * A choice of agent or target with one row the source can use is no
132
+ * choice: the flow takes that row and goes on, and Esc from the step after
133
+ * it goes back past it. A daemon that lists targets none of which can run
134
+ * the source shows the target step with the reason.
131
135
  * Typed input a source does not match against its candidates is read by
132
136
  * the source, then by the others in order. Every path out of the flow
133
137
  * either attaches the new session or returns the client to the screen it
@@ -155,6 +159,10 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
155
159
  // edit or a fresh install lands without a client restart.
156
160
  private picks: AgentPick[] = [];
157
161
 
162
+ // Whether the agent step waits on the daemon's targets and sources once
163
+ // an agent is chosen.
164
+ private sourcesPending = false;
165
+
158
166
  // The directories the directory step lists, and the label each shows.
159
167
  private dirs: string[] = [];
160
168
 
@@ -180,6 +188,10 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
180
188
 
181
189
  private target: TargetPick | null = null;
182
190
 
191
+ // Why the flow offers no target although the daemon lists some, or null
192
+ // when it offers some or the daemon lists none.
193
+ private targetGap: string | null = null;
194
+
183
195
  // The git source's candidates and the scope they were listed under, or
184
196
  // the notice that stands in for a list the daemon could not give, with
185
197
  // the source and target they were listed for. A listing runs behind the
@@ -265,7 +277,9 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
265
277
  this.input = '';
266
278
  this.sources = null;
267
279
  this.sourceIndex = -1;
280
+ this.sourcesPending = false;
268
281
  this.target = null;
282
+ this.targetGap = null;
269
283
  this.repo = null;
270
284
  this.ref = null;
271
285
  this.sourceDraft = '';
@@ -291,6 +305,14 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
291
305
  this.pending = null;
292
306
 
293
307
  process.stdout.write(ansi.clear);
308
+
309
+ // One installed agent is no choice to make, so the flow goes on with it.
310
+ if (this.picks.length === 1) {
311
+ void this.openFirstSource();
312
+
313
+ return;
314
+ }
315
+
294
316
  this.render();
295
317
  }
296
318
 
@@ -325,6 +347,17 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
325
347
  return;
326
348
  }
327
349
 
350
+ // The agent step waiting on the daemon's targets and sources takes Esc
351
+ // and the leader, and every other key waits for the answer.
352
+ if (
353
+ this.step === 'agent' &&
354
+ this.sourcesPending &&
355
+ edit.kind !== 'cancel' &&
356
+ edit.kind !== 'leader'
357
+ ) {
358
+ return;
359
+ }
360
+
328
361
  const tab = buf.length === 1 && buf[0] === KEY.tab;
329
362
 
330
363
  // A tab to a source of directories waits for its listing on the step it
@@ -387,10 +420,14 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
387
420
 
388
421
  // An empty menu means every configured binary is missing, so the hint
389
422
  // carries the fix instead of the movement keys.
390
- const hint =
391
- this.picks.length === 0
392
- ? 'no agent CLI found — set claudeBin, grokBin, or codexBin in config.json · esc cancel'
393
- : '↑↓ move · ⏎ select · esc cancel';
423
+ let hint = '↑↓ move · ⏎ select · esc cancel';
424
+
425
+ if (this.picks.length === 0) {
426
+ hint =
427
+ 'no agent CLI found — set claudeBin, grokBin, or codexBin in config.json · esc cancel';
428
+ } else if (this.sourcesPending) {
429
+ hint = 'reading targets and sources… · esc cancel';
430
+ }
394
431
 
395
432
  drawPicker({
396
433
  title: `${verb}: agent`,
@@ -488,13 +525,21 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
488
525
 
489
526
  const git = this.isGitFlow();
490
527
  const what = git ? 'the repository' : formatDir(this.dir);
528
+ let hint = `where ${what} runs · ↑↓ move · ⏎ select · esc back`;
529
+
530
+ // A step with no target the source can run on says what is missing.
531
+ if (this.collectEligibleTargets().length === 0) {
532
+ const gap = this.targetGap ?? `none of these targets can run ${what}`;
533
+
534
+ hint = `${gap} · set targets in config.json · esc back`;
535
+ }
491
536
 
492
537
  drawPicker({
493
538
  title: `${verb}: target`,
494
539
  items: this.targets.map((t) => formatTargetPick(t)),
495
540
  selected: this.selected,
496
541
  input: '',
497
- hint: this.refusal ?? `where ${what} runs · ↑↓ move · ⏎ select · esc back`,
542
+ hint: this.refusal ?? hint,
498
543
  dimmed: new Set(
499
544
  this.targets.flatMap((t, i) =>
500
545
  t.takesWorkspace || (!git && t.available && t.inPlace) ? [] : [i],
@@ -666,11 +711,19 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
666
711
 
667
712
  if (this.step === 'dir') {
668
713
  this.openAgentStep();
669
- } else if (this.step === 'source') {
714
+
715
+ return;
716
+ }
717
+
718
+ if (this.step === 'source') {
670
719
  this.sourceDraft = typed;
671
720
 
672
721
  this.applySourceCancel();
673
- } else if (this.step === 'ref') {
722
+
723
+ return;
724
+ }
725
+
726
+ if (this.step === 'ref') {
674
727
  this.input = this.repo?.label ?? '';
675
728
  this.repo = null;
676
729
  this.step = 'source';
@@ -684,10 +737,8 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
684
737
  if (this.isGitFlow()) {
685
738
  this.input = this.buildDestination();
686
739
  this.step = 'confirm';
687
- } else {
688
- this.openTargetStep(() => {
689
- this.step = 'dir';
690
- });
740
+ } else if (!this.openTargetStep()) {
741
+ this.step = 'dir';
691
742
  }
692
743
  } else {
693
744
  this.input = this.name;
@@ -698,20 +749,33 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
698
749
  this.render();
699
750
  }
700
751
 
752
+ // Goes back to the agent step, or leaves the flow when one installed
753
+ // agent left no agent step to go back to.
701
754
  private openAgentStep() {
755
+ if (this.picks.length === 1) {
756
+ this.quitFlow();
757
+
758
+ return;
759
+ }
760
+
702
761
  this.selected = Math.max(
703
762
  0,
704
763
  this.picks.findIndex((p) => p.agent === this.agent),
705
764
  );
706
765
 
766
+ this.sourcesPending = false;
707
767
  this.step = 'agent';
768
+
769
+ process.stdout.write(ansi.clear);
770
+ this.render();
708
771
  }
709
772
 
710
- // Leaves a git source step backwards: to the target step when the daemon
711
- // has more than one target, else to the agent.
773
+ // Leaves a git source step backwards: to the target step when the spawn
774
+ // has one, else to the agent.
712
775
  private applySourceCancel() {
713
- if (this.targets.length >= 2) {
714
- this.openTargetStep(() => {});
776
+ if (this.openTargetStep()) {
777
+ process.stdout.write(ansi.clear);
778
+ this.render();
715
779
 
716
780
  return;
717
781
  }
@@ -737,8 +801,6 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
737
801
 
738
802
  if (index === -1) {
739
803
  this.openAgentStep();
740
- process.stdout.write(ansi.clear);
741
- this.render();
742
804
 
743
805
  return;
744
806
  }
@@ -841,9 +903,9 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
841
903
  this.dir = dir;
842
904
  this.input = '';
843
905
 
844
- this.openTargetStep(() => {
906
+ if (!this.openTargetStep()) {
845
907
  this.step = 'name';
846
- });
908
+ }
847
909
 
848
910
  process.stdout.write(ansi.clear);
849
911
  this.render();
@@ -1028,8 +1090,8 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1028
1090
  }
1029
1091
 
1030
1092
  // Moves the flow to a source of git repositories, carrying the typed text
1031
- // across: the target step first when the daemon has more than one target
1032
- // and the flow had no repository target yet, then the source step, which
1093
+ // across: the target step first when the spawn has one and the flow had
1094
+ // no repository target yet, then the source step, which
1033
1095
  // acts as asked once it opens.
1034
1096
  private openGitSource(index: number, draft: string, action: SourceStepAction) {
1035
1097
  const fromGit = this.isGitFlow();
@@ -1045,16 +1107,13 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1045
1107
  return;
1046
1108
  }
1047
1109
 
1048
- if (this.targets.length >= 2) {
1049
- this.openTargetStep(() => {});
1110
+ if (this.openTargetStep()) {
1050
1111
  process.stdout.write(ansi.clear);
1051
1112
  this.render();
1052
1113
 
1053
1114
  return;
1054
1115
  }
1055
1116
 
1056
- this.target = this.targets[0] ?? null;
1057
-
1058
1117
  this.openSourceStep();
1059
1118
  }
1060
1119
 
@@ -1599,19 +1658,22 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1599
1658
  return this.target ?? this.targets.find((t) => t.isDefault) ?? null;
1600
1659
  }
1601
1660
 
1602
- // Opens the target step when the daemon has more than one target, and
1603
- // the fallback step otherwise. The chosen target is selected, else for a
1604
- // repository the default when it takes a workspace and the first target
1605
- // that does when it does not, else the default.
1606
- private openTargetStep(fallback: () => void) {
1607
- if (this.targets.length < 2) {
1608
- // The one target is the spawn's, default or not, so the spawn names it
1609
- // and its provider decides whether a directory runs in place.
1610
- this.target = this.targets[0] ?? null;
1661
+ // Opens the target step when more than one target can run the flow's
1662
+ // source, or none can although the daemon lists targets, and answers
1663
+ // whether it did. The chosen target is selected, else for a repository
1664
+ // the default when it takes a workspace and the first target that does
1665
+ // when it does not, else the default.
1666
+ private openTargetStep(): boolean {
1667
+ const eligible = this.collectEligibleTargets();
1611
1668
 
1612
- fallback();
1669
+ if (eligible.length === 1 || (this.targets.length === 0 && this.targetGap === null)) {
1670
+ // The one target that can run the source is the spawn's, default or
1671
+ // not, so the spawn names it and its provider decides whether a
1672
+ // directory runs in place. A daemon that lists no targets runs the
1673
+ // spawn on its default.
1674
+ this.target = eligible[0] ?? null;
1613
1675
 
1614
- return;
1676
+ return false;
1615
1677
  }
1616
1678
 
1617
1679
  const preferred =
@@ -1629,6 +1691,15 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1629
1691
 
1630
1692
  this.input = '';
1631
1693
  this.step = 'target';
1694
+
1695
+ return true;
1696
+ }
1697
+
1698
+ // The targets the flow's source can run on.
1699
+ private collectEligibleTargets(): TargetPick[] {
1700
+ const git = this.isGitFlow();
1701
+
1702
+ return this.targets.filter((t) => findTargetRefusal(t, git) === null);
1632
1703
  }
1633
1704
 
1634
1705
  // Reads the daemon's targets and sources once the agent is chosen, and
@@ -1638,6 +1709,10 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1638
1709
  const generation = this.generation;
1639
1710
  let listed: Readonly<Record<string, unknown>> = {};
1640
1711
 
1712
+ this.sourcesPending = true;
1713
+
1714
+ this.render();
1715
+
1641
1716
  try {
1642
1717
  listed = await this.deps.sendRequest('agents.list');
1643
1718
  } catch {}
@@ -1649,12 +1724,20 @@ export class SpawnPicker<TMirror extends { readonly id: string }> {
1649
1724
  // An agent that takes its credential from impd's broker runs only on a
1650
1725
  // target that reaches the broker.
1651
1726
  const brokered = isBrokerAgent(listed['agents'], this.agent);
1652
- const picks = collectTargetPicks(listed).filter((t) => !brokered || t.brokerAuth);
1727
+ const listedTargets = collectTargetPicks(listed);
1728
+ const picks = listedTargets.filter((t) => !brokered || t.brokerAuth);
1653
1729
 
1654
1730
  // An adopt resumes a session from its history on this host, which a
1655
1731
  // fresh checkout elsewhere does not hold, so it is offered only the
1656
1732
  // targets that run here.
1657
1733
  this.targets = this.resume ? picks.filter((t) => t.available && t.inPlace) : picks;
1734
+ this.targetGap = null;
1735
+
1736
+ if (listedTargets.length > 0 && picks.length === 0) {
1737
+ this.targetGap = `no target reaches the credential broker ${this.agent} needs`;
1738
+ } else if (listedTargets.length > 0 && this.targets.length === 0) {
1739
+ this.targetGap = 'no target on this host can adopt a session';
1740
+ }
1658
1741
 
1659
1742
  const announced = parseAnnouncedSources(listed['sources']);
1660
1743
  const index = this.resume ? announced.findIndex((source) => source.kind === 'path') : 0;
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import type { SessionState } from '../protocol/session-state';
3
3
  import type { AgentID } from '../shared/agent-id';
4
+ import { isRecord } from '../shared/report';
4
5
  import { toAgentID } from '../shared/to-agent-id';
5
6
 
6
7
  export interface MirrorSession {
@@ -20,8 +21,24 @@ export interface MirrorSession {
20
21
  canEject: boolean;
21
22
  agent: AgentID;
22
23
  parent: string | null;
24
+
25
+ // The execution target the session runs on, from the descriptor's
26
+ // locator; 'local' when the descriptor carries none.
27
+ target: string;
28
+
29
+ // The model the session was spawned with, as the daemon stores it: null
30
+ // when it runs the agent's default. An alias a gateway's models map
31
+ // resolves is resolved at draw time, where the map is at hand.
32
+ model: string | null;
33
+
34
+ // Where the session's harness stands: running, suspended inside a
35
+ // sleeping host, or exited. Distinct from `state`, which carries the
36
+ // attention a live harness last reported.
37
+ harness: HarnessLifecycle;
23
38
  }
24
39
 
40
+ export type HarnessLifecycle = 'running' | 'suspended' | 'exited';
41
+
25
42
  // Only the fields a mirror can't function without; an unparseable descriptor
26
43
  // is skipped rather than thrown into the event loop. Unknown keys pass
27
44
  // through so the lenient fallbacks below can still read them.
@@ -63,5 +80,32 @@ export function toMirrorSession(value: unknown): MirrorSession | null {
63
80
  canEject: record['canEject'] === true,
64
81
  agent: toAgentID(record['agent']),
65
82
  parent: typeof record['parent'] === 'string' ? record['parent'] : null,
83
+ target: readTargetID(record['locator']),
84
+ model: typeof record['model'] === 'string' ? record['model'] : null,
85
+ harness: readHarnessLifecycle(record['lifecycle'], record.alive),
66
86
  };
67
87
  }
88
+
89
+ // The locator's target id, with 'local' for a descriptor that carries no
90
+ // locator at all.
91
+ function readTargetID(locator: unknown): string {
92
+ if (isRecord(locator) && typeof locator['targetID'] === 'string') {
93
+ return locator['targetID'];
94
+ }
95
+
96
+ return 'local';
97
+ }
98
+
99
+ // The harness layer of a descriptor's lifecycle, falling back to what the
100
+ // alive flag says when the lifecycle is missing.
101
+ function readHarnessLifecycle(lifecycle: unknown, alive: boolean): HarnessLifecycle {
102
+ if (isRecord(lifecycle)) {
103
+ const harness = lifecycle['harness'];
104
+
105
+ if (harness === 'running' || harness === 'suspended' || harness === 'exited') {
106
+ return harness;
107
+ }
108
+ }
109
+
110
+ return alive ? 'running' : 'exited';
111
+ }
package/src/client/ui.ts CHANGED
@@ -3,9 +3,11 @@ import type { SessionState } from '../protocol/session-state';
3
3
  import type { AgentID } from '../shared/agent-id';
4
4
  import { RESET_INPUT_MODES } from '../shared/reset-input-modes';
5
5
  import { formatDir } from './dirs';
6
- import { formatOverlayAgentMark } from './format-overlay-agent-mark';
6
+ import { planOverlayColumns } from './plan-overlay-columns';
7
+ import type { OverlayColumnPlan } from './plan-overlay-columns';
7
8
  import { planVacatedRows } from './plan-vacated-rows';
8
9
  import type { BoxExtent } from './plan-vacated-rows';
10
+ import type { HarnessLifecycle } from './to-mirror-session';
9
11
 
10
12
  // The slice of a session the drawing layer needs; satisfied by both the
11
13
  // daemon's sessions and the wire descriptors a client mirrors.
@@ -39,13 +41,6 @@ const GLYPH: Record<SessionState, string> = {
39
41
  exited: `${ESC}[90m✗${ESC}[0m`,
40
42
  };
41
43
 
42
- const STATE_LABEL: Record<SessionState, string> = {
43
- needs_you: 'NEEDS YOU',
44
- running: 'running',
45
- done: 'done',
46
- exited: 'exited',
47
- };
48
-
49
44
  function out(s: string) {
50
45
  process.stdout.write(s);
51
46
  }
@@ -194,14 +189,25 @@ export interface OverlaySessionView extends SessionView {
194
189
  readonly agent: AgentID;
195
190
  readonly pinned: boolean;
196
191
  readonly repoRoot: string;
192
+ readonly target: string;
193
+ readonly model: string | null;
194
+
195
+ // Where the session's harness stands, apart from the attention its state
196
+ // carries: running, suspended inside a sleeping host, or exited.
197
+ readonly harness: HarnessLifecycle;
197
198
  }
198
199
 
199
200
  export interface OverlayView {
200
201
  sessions: readonly OverlaySessionView[];
201
202
 
202
- // The overlay column character per agent id, for the ids that do not use
203
- // the built-in one.
204
- agentMarks: Readonly<Record<AgentID, string>>;
203
+ // The readable name per agent id, and each agent's tier-alias map, from
204
+ // the daemon's `agents.list` answer over the config.
205
+ agentLabels: Readonly<Record<AgentID, string>>;
206
+ agentModels: Readonly<Record<AgentID, Readonly<Record<string, string>>>>;
207
+
208
+ // Whether more than one execution target is available to spawn on, which
209
+ // is what brings the target column in.
210
+ showTarget: boolean;
205
211
  selected: number;
206
212
  confirmKill: boolean;
207
213
 
@@ -213,6 +219,67 @@ export interface OverlayView {
213
219
  grouped: boolean;
214
220
  }
215
221
 
222
+ // A daemon-supplied id or alias can name an inherited property, so map
223
+ // reads go through an own-property check instead of indexing blindly.
224
+ function readMapValue<V>(map: Readonly<Record<string, V>>, key: string): V | undefined {
225
+ return Object.hasOwn(map, key) ? map[key] : undefined;
226
+ }
227
+
228
+ // A session's model as the row draws it: the daemon answer's alias target,
229
+ // or the raw model string when the answer maps nothing for it.
230
+ function resolveModelAlias(
231
+ models: Readonly<Record<AgentID, Readonly<Record<string, string>>>>,
232
+ agent: AgentID,
233
+ model: string,
234
+ ): string {
235
+ const aliases = readMapValue(models, agent);
236
+
237
+ return aliases === undefined ? model : (readMapValue(aliases, model) ?? model);
238
+ }
239
+
240
+ // A session row draws status (the attention glyph plus the unread mark),
241
+ // pin, name, the flat view's directory, then the target, harness, model,
242
+ // harness lifecycle, and last event columns the width plan allows.
243
+ export function buildSessionRow(
244
+ s: Readonly<OverlaySessionView>,
245
+ plan: Readonly<OverlayColumnPlan>,
246
+ view: Readonly<OverlayView>,
247
+ sel: boolean,
248
+ ): Row {
249
+ const sub = s.parent !== null && view.sessions.some((x) => x.id === s.parent);
250
+
251
+ const name =
252
+ plan.nameWidth >= 4 && sub
253
+ ? `↳ ${truncate(s.name, plan.nameWidth - 2)}`.padEnd(plan.nameWidth)
254
+ : truncate(s.name, plan.nameWidth).padEnd(plan.nameWidth);
255
+
256
+ const dir =
257
+ plan.dirWidth > 0 ? truncate(formatDir(s.cwd), plan.dirWidth).padEnd(plan.dirWidth) : '';
258
+
259
+ const target =
260
+ plan.targetWidth > 0 ? truncate(s.target, plan.targetWidth).padEnd(plan.targetWidth) : '';
261
+
262
+ const label = readMapValue(view.agentLabels, s.agent) ?? s.agent;
263
+ const harness = truncate(label, plan.harnessWidth).padEnd(plan.harnessWidth);
264
+ const resolved = s.model === null ? '' : resolveModelAlias(view.agentModels, s.agent, s.model);
265
+
266
+ const model =
267
+ plan.modelWidth > 0 ? truncate(resolved, plan.modelWidth).padEnd(plan.modelWidth) : '';
268
+
269
+ const lifecycle = truncate(s.harness, plan.lifecycleWidth).padEnd(plan.lifecycleWidth);
270
+ const event = truncate(s.lastMsg, plan.eventWidth).padEnd(plan.eventWidth);
271
+ const cells = [name, dir, target, harness, model, lifecycle, event].filter((cell) => cell !== '');
272
+ const body = cells.join(' ');
273
+ const styledBody = sel ? `${ESC}[7m${body}${ESC}[0m` : body;
274
+ const unread = s.unread ? `${ESC}[1;33m!${ESC}[0m` : ' ';
275
+ const pin = s.pinned ? `${ESC}[93m⋆${ESC}[0m` : ' ';
276
+
277
+ return {
278
+ styled: `${GLYPH[s.state]}${unread}${pin} ${styledBody}`,
279
+ width: 4 + body.length,
280
+ };
281
+ }
282
+
216
283
  export function drawOverlay(view: OverlayView) {
217
284
  const width = Math.min(cols() - 4, 90);
218
285
  const rowsList: Row[] = [boxTop(width, 'sessions')];
@@ -223,6 +290,35 @@ export function drawOverlay(view: OverlayView) {
223
290
  rowsList.push(dimRow(width, empty));
224
291
  }
225
292
 
293
+ // The harness label and the model each row would draw, so the columns
294
+ // take the width of what is actually on screen.
295
+ const harnessMax = Math.max(
296
+ 0,
297
+ ...view.sessions.map((s) => (readMapValue(view.agentLabels, s.agent) ?? s.agent).length),
298
+ );
299
+
300
+ const modelMax = Math.max(
301
+ 0,
302
+ ...view.sessions.map((s) => {
303
+ if (s.model === null) {
304
+ return 0;
305
+ }
306
+
307
+ return resolveModelAlias(view.agentModels, s.agent, s.model).length;
308
+ }),
309
+ );
310
+
311
+ const targetMax = Math.max(0, ...view.sessions.map((s) => s.target.length));
312
+
313
+ const plan = planOverlayColumns({
314
+ innerWidth: width - 4,
315
+ grouped: view.grouped,
316
+ showTarget: view.showTarget,
317
+ targetMax,
318
+ harnessMax,
319
+ modelMax,
320
+ });
321
+
226
322
  // The grouped view clusters sessions under dim repository headers, with
227
323
  // pinned sessions leading in their own cluster; the flat view shows a
228
324
  // directory column instead. The rows keep the sorted order either way. A
@@ -239,22 +335,9 @@ export function drawOverlay(view: OverlayView) {
239
335
  rowsList.push(dimRow(width, `▸ ${s.pinned ? 'pinned' : formatDir(s.repoRoot)}`));
240
336
  }
241
337
 
242
- const sel = i === view.selected;
243
- const name = (sub ? `↳ ${truncate(s.name, 14)}` : truncate(s.name, 16)).padEnd(16);
244
- const state = STATE_LABEL[s.state].padEnd(9);
245
- const dir = view.grouped ? '' : ` ${truncate(formatDir(s.cwd), 18).padEnd(18)}`;
246
- const msgWidth = Math.max(4, width - 4 - 4 - 17 - 10 - (view.grouped ? 0 : 19));
247
- const msg = truncate(s.lastMsg, msgWidth).padEnd(msgWidth);
248
- const unread = s.unread ? `${ESC}[1;33m!${ESC}[0m` : ' ';
249
- const pin = s.pinned ? `${ESC}[93m⋆${ESC}[0m` : ' ';
250
- const mark = formatOverlayAgentMark(s.agent, view.agentMarks);
251
- const styledMark = mark === ' ' ? mark : `${ESC}[90m${mark}${ESC}[0m`;
252
- const body = `${name} ${state}${dir} ${msg}`;
253
- const styledBody = sel ? `${ESC}[7m${body}${ESC}[0m` : body;
338
+ const row = buildSessionRow(s, plan, view, i === view.selected);
254
339
 
255
- rowsList.push(
256
- boxRow(width, `${GLYPH[s.state]}${unread}${pin}${styledMark}${styledBody}`, 4 + body.length),
257
- );
340
+ rowsList.push(boxRow(width, row.styled, row.width));
258
341
  }
259
342
 
260
343
  rowsList.push(boxDivider(width));
@@ -371,7 +454,6 @@ export function drawHelp() {
371
454
  'p pin or unpin — pinned sessions stay on top',
372
455
  ' a sub-session pins with its parent',
373
456
  'g toggle grouping by repository',
374
- ' Grok rows show a dim g after the pin mark',
375
457
  'n new session',
376
458
  'r adopt an external session',
377
459
  '/ filter · ↑↓/jk move · q quit',