tuiboard 0.11.0 → 0.12.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/.tuiboard/config.example.yaml +29 -9
- package/CHANGELOG.md +71 -0
- package/README.md +63 -30
- package/package.json +3 -2
- package/src/config/loader.ts +32 -7
- package/src/input/handleKey.ts +65 -51
- package/src/input/open-session.ts +403 -0
- package/src/store/agent-adapters/claude-code.ts +348 -0
- package/src/store/agent-adapters/codex.ts +405 -0
- package/src/store/agent-adapters/index.ts +17 -0
- package/src/store/agent-adapters/opencode.ts +195 -0
- package/src/store/agent-adapters/pi.ts +253 -0
- package/src/store/agents.ts +139 -336
- package/src/store/index.ts +45 -7
- package/src/ui/AgentRow.tsx +147 -41
- package/src/ui/AgentsBar.tsx +8 -3
- package/src/ui/Modal.tsx +40 -6
- package/src/ui/agent-line.ts +95 -0
- package/src/views/AgentsOnly.tsx +11 -5
package/src/ui/AgentRow.tsx
CHANGED
|
@@ -1,19 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* dashboard strip) and AgentsOnly (fullscreen list).
|
|
2
|
+
* Render of an AgentSession, in two densities:
|
|
4
3
|
*
|
|
5
|
-
*
|
|
4
|
+
* line (AgentsBar — the 5-row dashboard strip)
|
|
5
|
+
* cursor · status · harness · name · branch · model ……right-pinned: cwd_short · age
|
|
6
|
+
* Fitted to the measured row width (see agent-line.ts): on a narrow row the
|
|
7
|
+
* model goes first, then the branch, then the cwd — the name survives longest.
|
|
8
|
+
*
|
|
9
|
+
* card (AgentsOnly — fullscreen / zoomed Agents view)
|
|
10
|
+
* cursor · status · harness · name ………………………………… age
|
|
11
|
+
* model · branch · cwd (dimmed)
|
|
6
12
|
*/
|
|
7
13
|
|
|
8
|
-
import { Show, createMemo } from "solid-js";
|
|
14
|
+
import { Show, createMemo, createSignal, onMount } from "solid-js";
|
|
15
|
+
import { homedir } from "node:os";
|
|
9
16
|
|
|
17
|
+
import { layoutCardDetails, layoutCardName, layoutLine } from "~/ui/agent-line";
|
|
10
18
|
import { T } from "~/ui/glyphs";
|
|
11
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
HARNESS,
|
|
21
|
+
cwdShort,
|
|
22
|
+
formatAge,
|
|
23
|
+
shortModel,
|
|
24
|
+
type AgentProvider,
|
|
25
|
+
type AgentSession,
|
|
26
|
+
type AgentStatus,
|
|
27
|
+
} from "~/store/agents";
|
|
12
28
|
|
|
13
29
|
const STATUS_COLOR: Record<AgentStatus, string> = {
|
|
14
30
|
"live-busy": T.today, // bright accent for actively-running
|
|
15
31
|
"live-idle": T.scheduled, // warm but quieter
|
|
16
|
-
"stale
|
|
32
|
+
"stale": T.bannerWarn,
|
|
17
33
|
"dormant": T.textDim,
|
|
18
34
|
"archived": T.textDone,
|
|
19
35
|
};
|
|
@@ -21,16 +37,35 @@ const STATUS_COLOR: Record<AgentStatus, string> = {
|
|
|
21
37
|
const STATUS_GLYPH: Record<AgentStatus, string> = {
|
|
22
38
|
"live-busy": "●",
|
|
23
39
|
"live-idle": "○",
|
|
24
|
-
"stale
|
|
40
|
+
"stale": "△",
|
|
25
41
|
"dormant": "·",
|
|
26
42
|
"archived": "·",
|
|
27
43
|
};
|
|
28
44
|
|
|
45
|
+
/** One hue per harness so the badge reads before the letters do. */
|
|
46
|
+
export const HARNESS_COLOR: Record<AgentProvider, string> = {
|
|
47
|
+
"claude-code": T.warm, // orange
|
|
48
|
+
codex: T.accent, // blue-cyan
|
|
49
|
+
opencode: "#d27ee0", // violet-fuchsia
|
|
50
|
+
pi: "#c3d94e", // acid yellow-green
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Card cwd: `~`-relative when under $HOME, shortened when still long. */
|
|
54
|
+
const HOME = homedir();
|
|
55
|
+
const CARD_CWD_MAX = 60;
|
|
56
|
+
function cardCwd(cwd: string): string {
|
|
57
|
+
const underHome = cwd === HOME || cwd.startsWith(HOME + "/") || cwd.startsWith(HOME + "\\");
|
|
58
|
+
const shown = underHome ? "~" + cwd.slice(HOME.length) : cwd;
|
|
59
|
+
return shown.length > CARD_CWD_MAX ? cwdShort(cwd) : shown;
|
|
60
|
+
}
|
|
61
|
+
|
|
29
62
|
interface AgentRowProps {
|
|
30
63
|
session: AgentSession;
|
|
31
64
|
cursor?: boolean;
|
|
32
65
|
/** Maximum chars for displayName before truncation. Default 40. */
|
|
33
66
|
nameMaxChars?: number;
|
|
67
|
+
/** `line` (default) for the dashboard strip, `card` for the fullscreen view. */
|
|
68
|
+
variant?: "line" | "card";
|
|
34
69
|
onClick?: () => void;
|
|
35
70
|
}
|
|
36
71
|
|
|
@@ -43,42 +78,113 @@ export function AgentRow(props: AgentRowProps) {
|
|
|
43
78
|
const n = props.session.displayName;
|
|
44
79
|
return n.length > nameMax() ? n.slice(0, nameMax() - 1) + "…" : n;
|
|
45
80
|
});
|
|
81
|
+
const model = createMemo(() => shortModel(props.session.model));
|
|
82
|
+
|
|
83
|
+
// Measured row width; undefined until the first layout pass.
|
|
84
|
+
const [width, setWidth] = createSignal<number | undefined>();
|
|
85
|
+
let rowRef: { width: number } | undefined;
|
|
86
|
+
const measure = () => {
|
|
87
|
+
if (rowRef && rowRef.width > 0) setWidth(rowRef.width);
|
|
88
|
+
};
|
|
89
|
+
onMount(() => setTimeout(measure, 0));
|
|
90
|
+
const sizeProps = {
|
|
91
|
+
ref: (r: { width: number }) => (rowRef = r),
|
|
92
|
+
onSizeChange: measure,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const line = createMemo(() =>
|
|
96
|
+
layoutLine(
|
|
97
|
+
{
|
|
98
|
+
name: displayName(),
|
|
99
|
+
branch: props.session.gitBranch,
|
|
100
|
+
model: model(),
|
|
101
|
+
cwd: props.session.cwdShort,
|
|
102
|
+
age: ageStr().padStart(3),
|
|
103
|
+
},
|
|
104
|
+
width(),
|
|
105
|
+
),
|
|
106
|
+
);
|
|
107
|
+
const cardName = createMemo(() => layoutCardName(displayName(), width()));
|
|
108
|
+
const details = createMemo(() =>
|
|
109
|
+
layoutCardDetails([model(), props.session.gitBranch, cardCwd(props.session.cwd)], width()),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// cursor · status glyph · harness badge — shared by both variants.
|
|
113
|
+
const lead = () => [
|
|
114
|
+
<span style={{ fg: props.cursor ? T.accent : T.textDim }}>
|
|
115
|
+
{props.cursor ? "▶ " : " "}
|
|
116
|
+
</span>,
|
|
117
|
+
<span style={{ fg: STATUS_COLOR[props.session.status] }}>
|
|
118
|
+
{STATUS_GLYPH[props.session.status]}{" "}
|
|
119
|
+
</span>,
|
|
120
|
+
<span style={{ fg: HARNESS_COLOR[props.session.provider] }}>
|
|
121
|
+
{HARNESS[props.session.provider].code}{" "}
|
|
122
|
+
</span>,
|
|
123
|
+
];
|
|
46
124
|
|
|
47
125
|
return (
|
|
48
|
-
<
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
126
|
+
<Show
|
|
127
|
+
when={props.variant === "card"}
|
|
128
|
+
fallback={
|
|
129
|
+
<box
|
|
130
|
+
{...sizeProps}
|
|
131
|
+
style={{
|
|
132
|
+
flexDirection: "row",
|
|
133
|
+
paddingLeft: 1,
|
|
134
|
+
paddingRight: 1,
|
|
135
|
+
backgroundColor: props.cursor ? T.cardBgCursor : undefined,
|
|
136
|
+
}}
|
|
137
|
+
onMouseDown={props.onClick ? (() => props.onClick!()) : undefined}
|
|
138
|
+
>
|
|
139
|
+
{/* `truncate` stays on as a safety net only — layoutLine already
|
|
140
|
+
fits the fields to the measured width with a tail cut, while
|
|
141
|
+
OpenTUI's own truncation would elide mid-string. */}
|
|
142
|
+
<text style={{ flexGrow: 1, flexShrink: 1 }} wrapMode="none" truncate>
|
|
143
|
+
{lead()}
|
|
144
|
+
<span style={{ fg: T.text }}>{line().name}</span>
|
|
145
|
+
<Show when={line().branch}>
|
|
146
|
+
<span style={{ fg: T.warmDim }}>{" "}{line().branch}</span>
|
|
147
|
+
</Show>
|
|
148
|
+
<Show when={line().model}>
|
|
149
|
+
<span style={{ fg: T.textDim }}>{" "}{line().model}</span>
|
|
150
|
+
</Show>
|
|
151
|
+
</text>
|
|
152
|
+
{/* cwd + age pinned together on the right. The age is right-aligned in a
|
|
153
|
+
fixed-width field (pad to 3: "59m" / "23h" / "10d") so the END of each
|
|
154
|
+
cwd lands on the same column across rows — a 1- vs 2-digit age no
|
|
155
|
+
longer shoves the directory names out of vertical alignment. */}
|
|
156
|
+
<text style={{ flexShrink: 0 }} wrapMode="none">
|
|
157
|
+
<span style={{ fg: T.textDim }}>{" "}{line().right}</span>
|
|
158
|
+
</text>
|
|
159
|
+
</box>
|
|
160
|
+
}
|
|
56
161
|
>
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
162
|
+
<box
|
|
163
|
+
{...sizeProps}
|
|
164
|
+
style={{
|
|
165
|
+
flexDirection: "column",
|
|
166
|
+
paddingLeft: 1,
|
|
167
|
+
paddingRight: 1,
|
|
168
|
+
backgroundColor: props.cursor ? T.cardBgCursor : undefined,
|
|
169
|
+
}}
|
|
170
|
+
onMouseDown={props.onClick ? (() => props.onClick!()) : undefined}
|
|
171
|
+
>
|
|
172
|
+
<box style={{ flexDirection: "row" }}>
|
|
173
|
+
<text style={{ flexGrow: 1, flexShrink: 1 }} wrapMode="none" truncate>
|
|
174
|
+
{lead()}
|
|
175
|
+
<span style={{ fg: T.text }}>{cardName()}</span>
|
|
176
|
+
</text>
|
|
177
|
+
<text style={{ flexShrink: 0 }} wrapMode="none">
|
|
178
|
+
<span style={{ fg: T.textDim }}>{" "}{ageStr().padStart(3)}</span>
|
|
179
|
+
</text>
|
|
180
|
+
</box>
|
|
181
|
+
{/* Indent = width of "▶ ● cc " so the details line up under the name. */}
|
|
182
|
+
<box style={{ flexDirection: "row", paddingLeft: 7 }}>
|
|
183
|
+
<text style={{ flexGrow: 1, flexShrink: 1 }} wrapMode="none" truncate>
|
|
184
|
+
<span style={{ fg: T.textDim }}>{details()}</span>
|
|
185
|
+
</text>
|
|
186
|
+
</box>
|
|
187
|
+
</box>
|
|
188
|
+
</Show>
|
|
83
189
|
);
|
|
84
190
|
}
|
package/src/ui/AgentsBar.tsx
CHANGED
|
@@ -12,6 +12,7 @@ import { For, Show, createEffect, createMemo } from "solid-js";
|
|
|
12
12
|
|
|
13
13
|
import { AgentRow } from "~/ui/AgentRow";
|
|
14
14
|
import { T } from "~/ui/glyphs";
|
|
15
|
+
import { HARNESS } from "~/store/agents";
|
|
15
16
|
import type { TuiStore } from "~/store/index";
|
|
16
17
|
|
|
17
18
|
interface ScrollBoxLike {
|
|
@@ -31,10 +32,14 @@ export function AgentsBar(props: AgentsBarProps) {
|
|
|
31
32
|
const isActive = () => props.store.state.ui.activeZone === "agents";
|
|
32
33
|
const agentRow = () => props.store.state.ui.row;
|
|
33
34
|
|
|
34
|
-
/** All visible (non-archived) sessions. */
|
|
35
|
+
/** All visible (non-archived) sessions, after the harness filter. */
|
|
35
36
|
const allShown = createMemo(() =>
|
|
36
|
-
props.store.
|
|
37
|
+
props.store.agentSessions().filter((s) => s.status !== "archived"),
|
|
37
38
|
);
|
|
39
|
+
const filterTag = () => {
|
|
40
|
+
const f = props.store.state.ui.agentsFilter;
|
|
41
|
+
return f === "all" ? "" : ` · ${HARNESS[f].code}`;
|
|
42
|
+
};
|
|
38
43
|
|
|
39
44
|
let scrollBoxRef: ScrollBoxLike | undefined;
|
|
40
45
|
|
|
@@ -68,7 +73,7 @@ export function AgentsBar(props: AgentsBarProps) {
|
|
|
68
73
|
paddingLeft: 1,
|
|
69
74
|
paddingRight: 1,
|
|
70
75
|
}}
|
|
71
|
-
title={`┤ Agents (live) · ${allShown().length} ├`}
|
|
76
|
+
title={`┤ Agents (live)${filterTag()} · ${allShown().length} ├`}
|
|
72
77
|
titleAlignment="left"
|
|
73
78
|
>
|
|
74
79
|
<Show
|
package/src/ui/Modal.tsx
CHANGED
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
import { ATTR, T, cellWidth } from "~/ui/glyphs";
|
|
23
23
|
import { AGENDA_WIDTH } from "~/ui/layout";
|
|
24
24
|
import { formatHm } from "~/store/timeline";
|
|
25
|
+
import { HARNESS } from "~/store/agents";
|
|
26
|
+
import { HARNESS_COLOR } from "~/ui/AgentRow";
|
|
25
27
|
import type { TuiStore } from "~/store/index";
|
|
26
28
|
import type { PriorityLevel, TimeBlock } from "~/types";
|
|
27
29
|
|
|
@@ -919,6 +921,17 @@ function AgentDetailModal(props: { store: TuiStore; modal: Extract<NonNullable<T
|
|
|
919
921
|
<span style={{ fg: T.textDim }}>session </span>
|
|
920
922
|
<span style={{ fg: T.accent }}>{s().sessionId}</span>
|
|
921
923
|
</text>
|
|
924
|
+
<text>
|
|
925
|
+
<span style={{ fg: T.textDim }}>harness </span>
|
|
926
|
+
<span style={{ fg: HARNESS_COLOR[s().provider] }}>{HARNESS[s().provider].code}</span>
|
|
927
|
+
<span style={{ fg: T.text }}>{" "}{HARNESS[s().provider].name}</span>
|
|
928
|
+
</text>
|
|
929
|
+
<Show when={s().model}>
|
|
930
|
+
<text>
|
|
931
|
+
<span style={{ fg: T.textDim }}>model </span>
|
|
932
|
+
<span style={{ fg: T.text }}>{s().model}</span>
|
|
933
|
+
</text>
|
|
934
|
+
</Show>
|
|
922
935
|
<text>
|
|
923
936
|
<span style={{ fg: T.textDim }}>status </span>
|
|
924
937
|
<span style={{ fg: T.text }}>{s().status}</span>
|
|
@@ -973,17 +986,37 @@ function AgentDetailModal(props: { store: TuiStore; modal: Extract<NonNullable<T
|
|
|
973
986
|
</span>
|
|
974
987
|
</text>
|
|
975
988
|
</Show>
|
|
989
|
+
<Show
|
|
990
|
+
when={
|
|
991
|
+
props.store.state.ui.lastLaunch?.sessionId === s().sessionId
|
|
992
|
+
? props.store.state.ui.lastLaunch
|
|
993
|
+
: undefined
|
|
994
|
+
}
|
|
995
|
+
>
|
|
996
|
+
{(l: () => NonNullable<TuiStore["state"]["ui"]["lastLaunch"]>) => (
|
|
997
|
+
<>
|
|
998
|
+
<box style={{ height: 1 }} />
|
|
999
|
+
<text>
|
|
1000
|
+
<span style={{ fg: T.textDim }}>last Enter ({new Date(l().ts).toLocaleTimeString()}):</span>
|
|
1001
|
+
</text>
|
|
1002
|
+
<text wrapMode="word">
|
|
1003
|
+
<span style={{ fg: l().ok ? T.done : T.overdue }}>{l().text}</span>
|
|
1004
|
+
</text>
|
|
1005
|
+
</>
|
|
1006
|
+
)}
|
|
1007
|
+
</Show>
|
|
976
1008
|
<box style={{ height: 1 }} />
|
|
977
1009
|
<text>
|
|
978
1010
|
<span style={{ fg: T.textDim }}>
|
|
979
|
-
resume — Enter opens this in
|
|
1011
|
+
resume — Enter opens this in a new terminal tab; c copies this command to paste anywhere:
|
|
980
1012
|
</span>
|
|
981
1013
|
</text>
|
|
982
1014
|
<text wrapMode="word">
|
|
983
1015
|
<span style={{ fg: T.scheduled }}>
|
|
984
1016
|
{props.store.config.copyResumeCommand
|
|
985
1017
|
.replaceAll("{cwd}", s().cwd)
|
|
986
|
-
.replaceAll("{sessionId}", s().sessionId)
|
|
1018
|
+
.replaceAll("{sessionId}", s().sessionId)
|
|
1019
|
+
.replaceAll("{resume}", s().resumeCommand)}
|
|
987
1020
|
</span>
|
|
988
1021
|
</text>
|
|
989
1022
|
</box>
|
|
@@ -1085,7 +1118,7 @@ const HELP_SECTIONS: HelpSection[] = [
|
|
|
1085
1118
|
rows: [
|
|
1086
1119
|
["n", "New task in current column (quick-add syntax)"],
|
|
1087
1120
|
["g", "Grab task — h/l then moves it between columns; g/Esc to drop"],
|
|
1088
|
-
["f", "Cycle board filter: all → today → overdue → tomorrow → followup"],
|
|
1121
|
+
["f", "Cycle board filter: all → today → overdue → tomorrow → followup (Agents zone: harness filter)"],
|
|
1089
1122
|
["/", "Search task titles — jumps cursor to first match"],
|
|
1090
1123
|
],
|
|
1091
1124
|
},
|
|
@@ -1093,9 +1126,10 @@ const HELP_SECTIONS: HelpSection[] = [
|
|
|
1093
1126
|
emoji: "🤖",
|
|
1094
1127
|
title: "Agents zone",
|
|
1095
1128
|
rows: [
|
|
1096
|
-
["Enter", "Open (resume) the selected session in a new
|
|
1097
|
-
["c", "Copy a 'cd +
|
|
1098
|
-
["o", "Session detail (cwd, branch, last prompts, resume cmd)"],
|
|
1129
|
+
["Enter", "Open (resume) the selected session in a new terminal tab/window"],
|
|
1130
|
+
["c", "Copy a 'cd + resume' command for the session"],
|
|
1131
|
+
["o", "Session detail (harness, model, cwd, branch, last prompts, resume cmd)"],
|
|
1132
|
+
["f", "Filter by harness: all → cc Claude Code → cx Codex → oc OpenCode → pi Pi"],
|
|
1099
1133
|
],
|
|
1100
1134
|
},
|
|
1101
1135
|
{
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Width-aware text layout for AgentRow. Pure (no JSX) so it's unit-testable.
|
|
3
|
+
*
|
|
4
|
+
* OpenTUI's own `truncate` elides in the MIDDLE of a text run, which mangles a
|
|
5
|
+
* row made of several fields ("Review the harn...badge-23 gpt-5.5…"). We fit
|
|
6
|
+
* the fields ourselves, dropping the least important ones first and always
|
|
7
|
+
* cutting at the tail.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { cellWidth } from "~/ui/glyphs";
|
|
11
|
+
|
|
12
|
+
/** "▶ " + "● " + "cc " — cursor, status glyph, harness badge. */
|
|
13
|
+
export const LEAD_CELLS = 7;
|
|
14
|
+
/** Row box paddingLeft + paddingRight. */
|
|
15
|
+
const ROW_PADDING = 2;
|
|
16
|
+
/** Cells kept free for width-measurement drift (same margin as zoomed TaskRow). */
|
|
17
|
+
const DRIFT_MARGIN = 2;
|
|
18
|
+
const GAP = " ";
|
|
19
|
+
|
|
20
|
+
/** Cut `s` to at most `max` cells, ending in "…" when cut. */
|
|
21
|
+
export function fitCells(s: string, max: number): string {
|
|
22
|
+
if (max <= 0) return "";
|
|
23
|
+
if (cellWidth(s) <= max) return s;
|
|
24
|
+
let out = "";
|
|
25
|
+
let w = 0;
|
|
26
|
+
for (const ch of s) {
|
|
27
|
+
const cw = cellWidth(ch);
|
|
28
|
+
if (w + cw > max - 1) break;
|
|
29
|
+
out += ch;
|
|
30
|
+
w += cw;
|
|
31
|
+
}
|
|
32
|
+
return out + "…";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface LineFields {
|
|
36
|
+
name: string;
|
|
37
|
+
branch?: string;
|
|
38
|
+
model?: string;
|
|
39
|
+
/** Right-pinned, e.g. "…Repos/Personal/tuiboard". */
|
|
40
|
+
cwd: string;
|
|
41
|
+
/** Right-pinned, already padded, e.g. " 3m". */
|
|
42
|
+
age: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface LineLayout {
|
|
46
|
+
name: string;
|
|
47
|
+
branch?: string;
|
|
48
|
+
model?: string;
|
|
49
|
+
/** Right-pinned text, empty cwd when there's no room for it. */
|
|
50
|
+
right: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Below this many cells for the name, the cwd gives way first. */
|
|
54
|
+
const MIN_NAME_CELLS = 16;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Fit a one-line row into `width` cells (the row box's full width).
|
|
58
|
+
* Drop order when space runs out: model, then branch (a short model still
|
|
59
|
+
* shows if only a long branch doesn't fit), then cwd; the name is shortened
|
|
60
|
+
* last. Unknown width (not measured yet) → everything, uncut.
|
|
61
|
+
*/
|
|
62
|
+
export function layoutLine(f: LineFields, width: number | undefined): LineLayout {
|
|
63
|
+
if (!width) return { name: f.name, branch: f.branch, model: f.model, right: `${f.cwd}${GAP}${f.age}` };
|
|
64
|
+
const inner = width - ROW_PADDING - DRIFT_MARGIN - LEAD_CELLS;
|
|
65
|
+
|
|
66
|
+
let right = `${f.cwd}${GAP}${f.age}`;
|
|
67
|
+
if (inner - cellWidth(right) - cellWidth(GAP) < MIN_NAME_CELLS) right = f.age;
|
|
68
|
+
const budget = inner - cellWidth(right) - cellWidth(GAP);
|
|
69
|
+
|
|
70
|
+
const extras = (b?: string, m?: string) =>
|
|
71
|
+
(b ? cellWidth(GAP + b) : 0) + (m ? cellWidth(GAP + m) : 0);
|
|
72
|
+
const nameW = cellWidth(f.name);
|
|
73
|
+
for (const [b, m] of [
|
|
74
|
+
[f.branch, f.model],
|
|
75
|
+
[f.branch, undefined],
|
|
76
|
+
[undefined, f.model],
|
|
77
|
+
[undefined, undefined],
|
|
78
|
+
] as const) {
|
|
79
|
+
if (nameW + extras(b, m) <= budget) return { name: f.name, branch: b, model: m, right };
|
|
80
|
+
}
|
|
81
|
+
return { name: fitCells(f.name, budget), right };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Fit the card's detail line (`model · branch · cwd`) with a tail cut. */
|
|
85
|
+
export function layoutCardDetails(parts: (string | undefined)[], width: number | undefined): string {
|
|
86
|
+
const text = parts.filter((p): p is string => Boolean(p)).join(" · ");
|
|
87
|
+
if (!width) return text;
|
|
88
|
+
return fitCells(text, width - ROW_PADDING - DRIFT_MARGIN - LEAD_CELLS);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Fit the card's title (the age takes GAP + 3 cells on the right). */
|
|
92
|
+
export function layoutCardName(name: string, width: number | undefined): string {
|
|
93
|
+
if (!width) return name;
|
|
94
|
+
return fitCells(name, width - ROW_PADDING - DRIFT_MARGIN - LEAD_CELLS - cellWidth(GAP) - 3);
|
|
95
|
+
}
|
package/src/views/AgentsOnly.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Fullscreen list of every local Claude Code
|
|
2
|
+
* Fullscreen list of every local agent session (Claude Code, Codex, OpenCode, Pi).
|
|
3
3
|
* `tuiboard --view=agents`. Shows ALL sessions (including archived),
|
|
4
|
-
* scrollable, cursor-navigable. The scrollbox follows the cursor via
|
|
4
|
+
* scrollable, cursor-navigable, as two-line cards (see AgentRow). The scrollbox follows the cursor via
|
|
5
5
|
* scrollChildIntoView (same trick used in BoardView for active columns).
|
|
6
6
|
*/
|
|
7
7
|
|
|
@@ -9,6 +9,7 @@ import { For, Show, createEffect, createMemo } from "solid-js";
|
|
|
9
9
|
|
|
10
10
|
import { AgentRow } from "~/ui/AgentRow";
|
|
11
11
|
import { T } from "~/ui/glyphs";
|
|
12
|
+
import { HARNESS } from "~/store/agents";
|
|
12
13
|
import type { TuiStore } from "~/store/index";
|
|
13
14
|
|
|
14
15
|
interface ScrollBoxLike {
|
|
@@ -24,7 +25,9 @@ function rowId(sessionId: string): string {
|
|
|
24
25
|
export function AgentsOnly(props: { store: TuiStore }) {
|
|
25
26
|
const isActive = () => props.store.state.ui.activeZone === "agents";
|
|
26
27
|
const agentRow = () => props.store.state.ui.row;
|
|
27
|
-
const sessions = createMemo(() => props.store.
|
|
28
|
+
const sessions = createMemo(() => props.store.agentSessions());
|
|
29
|
+
const filter = () => props.store.state.ui.agentsFilter;
|
|
30
|
+
const filterTag = () => (filter() === "all" ? "" : ` · ${HARNESS[filter() as keyof typeof HARNESS].code}`);
|
|
28
31
|
let scrollBoxRef: ScrollBoxLike | undefined;
|
|
29
32
|
|
|
30
33
|
// Auto-scroll the list so the active row is visible. setTimeout(0) waits
|
|
@@ -55,7 +58,7 @@ export function AgentsOnly(props: { store: TuiStore }) {
|
|
|
55
58
|
paddingLeft: 1,
|
|
56
59
|
paddingRight: 1,
|
|
57
60
|
}}
|
|
58
|
-
title={`┤ Agents · ${sessions().length} sessions ├`}
|
|
61
|
+
title={`┤ Agents${filterTag()} · ${sessions().length} sessions ├`}
|
|
59
62
|
titleAlignment="left"
|
|
60
63
|
>
|
|
61
64
|
<Show
|
|
@@ -63,7 +66,9 @@ export function AgentsOnly(props: { store: TuiStore }) {
|
|
|
63
66
|
fallback={
|
|
64
67
|
<text>
|
|
65
68
|
<span style={{ fg: T.textDim }}>
|
|
66
|
-
|
|
69
|
+
{filter() === "all"
|
|
70
|
+
? "No agent sessions found (Claude Code, Codex, OpenCode, Pi)."
|
|
71
|
+
: `No ${HARNESS[filter() as keyof typeof HARNESS].name} sessions — press f to change the filter.`}
|
|
67
72
|
</span>
|
|
68
73
|
</text>
|
|
69
74
|
}
|
|
@@ -87,6 +92,7 @@ export function AgentsOnly(props: { store: TuiStore }) {
|
|
|
87
92
|
session={session}
|
|
88
93
|
cursor={isActive() && i() === agentRow()}
|
|
89
94
|
nameMaxChars={120}
|
|
95
|
+
variant="card"
|
|
90
96
|
onClick={() => {
|
|
91
97
|
props.store.setActiveZone("agents");
|
|
92
98
|
props.store.setCursor(0, i());
|