tuiboard 0.10.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 +105 -0
- package/README.md +63 -30
- package/package.json +3 -2
- package/src/config/loader.ts +32 -7
- package/src/input/handleKey.ts +71 -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 +63 -7
- package/src/ui/AgentRow.tsx +147 -41
- package/src/ui/AgentsBar.tsx +8 -3
- package/src/ui/BoardView.tsx +35 -4
- package/src/ui/Modal.tsx +263 -68
- package/src/ui/agent-line.ts +95 -0
- package/src/views/AgentsOnly.tsx +11 -5
package/src/ui/Modal.tsx
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* checks `ui.modal` first and bails if set (only Escape passes through).
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { For, Show, createContext, createMemo, createSignal, useContext } from "solid-js";
|
|
14
|
+
import { For, Show, createContext, createEffect, createMemo, createSignal, useContext } from "solid-js";
|
|
15
15
|
|
|
16
16
|
import { isTask } from "~/parser/markdown";
|
|
17
17
|
import {
|
|
@@ -19,9 +19,11 @@ import {
|
|
|
19
19
|
parseQuickAdd,
|
|
20
20
|
parseTimeBlockShortcut,
|
|
21
21
|
} from "~/store/parsers";
|
|
22
|
-
import { ATTR, T } from "~/ui/glyphs";
|
|
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>
|
|
@@ -1001,72 +1034,234 @@ function truncate(s: string, n: number): string {
|
|
|
1001
1034
|
|
|
1002
1035
|
// ─── Help ────────────────────────────────────────────────────────────────────
|
|
1003
1036
|
|
|
1037
|
+
/**
|
|
1038
|
+
* Width (cells) of the key column. The longest key label is 15 cells
|
|
1039
|
+
* ("shift+click row"), so 16 leaves room for a one-cell separator and keeps
|
|
1040
|
+
* every description starting at the same column. `cellWidth` (not `.length`)
|
|
1041
|
+
* is used so wide arrow/emoji glyphs are budgeted correctly.
|
|
1042
|
+
*/
|
|
1043
|
+
const HELP_KEYW = 16;
|
|
1044
|
+
|
|
1045
|
+
interface HelpSection {
|
|
1046
|
+
emoji: string;
|
|
1047
|
+
title: string;
|
|
1048
|
+
/** Scope note shown dim under the header (kept verbatim from the original). */
|
|
1049
|
+
note?: string;
|
|
1050
|
+
/** [keyLabel, description] pairs — descriptions are verbatim. */
|
|
1051
|
+
rows: [string, string][];
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const HELP_SECTIONS: HelpSection[] = [
|
|
1055
|
+
{
|
|
1056
|
+
emoji: "🧭",
|
|
1057
|
+
title: "Navigation",
|
|
1058
|
+
rows: [
|
|
1059
|
+
["h j k l ←↑↓→", "Move cursor inside the active zone"],
|
|
1060
|
+
["Tab", "Next board (kanban zone)"],
|
|
1061
|
+
["+", "New board — create one, or adopt files you have"],
|
|
1062
|
+
["1..9", "Jump to board N"],
|
|
1063
|
+
["v", "Toggle Today/Tomorrow planner panel focus"],
|
|
1064
|
+
["Shift-Tab", "Cycle active zone (planner → board → timeline → agents)"],
|
|
1065
|
+
["F1 / F2 / F3", "Toggle visibility of Planner / Timeline / Agents zones"],
|
|
1066
|
+
["z", "Zoom active zone (or column) to full screen"],
|
|
1067
|
+
["r", "Refresh everything (boards from disk, agents, agenda calendar)"],
|
|
1068
|
+
],
|
|
1069
|
+
},
|
|
1070
|
+
{
|
|
1071
|
+
emoji: "⚙️",
|
|
1072
|
+
title: "Task actions",
|
|
1073
|
+
note: "work in board, planner, AND timeline zones",
|
|
1074
|
+
rows: [
|
|
1075
|
+
["Enter", "Toggle done"],
|
|
1076
|
+
["o", "Open detail view"],
|
|
1077
|
+
["e", "Edit task text"],
|
|
1078
|
+
["s", "Schedule date modal (t/m/+N/lun/YYYY-MM-DD — same t/m as the board)"],
|
|
1079
|
+
["t", "Set scheduled = today"],
|
|
1080
|
+
["m", "Set scheduled = tomorrow"],
|
|
1081
|
+
[".", "Schedule now — time block at next 15-min slot (30min)"],
|
|
1082
|
+
["b", "Set time block modal"],
|
|
1083
|
+
["p", "Cycle priority (none → 🔺 → ⏫ → 🔼 → 🔽 → ⏬ → none)"],
|
|
1084
|
+
["a", "Set assignee"],
|
|
1085
|
+
["d", "Delete task (with confirm)"],
|
|
1086
|
+
["X", "Archive task → moves to Archive column"],
|
|
1087
|
+
["C", "Copy task to clipboard (markdown line)"],
|
|
1088
|
+
],
|
|
1089
|
+
},
|
|
1090
|
+
{
|
|
1091
|
+
emoji: "📅",
|
|
1092
|
+
title: "Agenda — day navigation",
|
|
1093
|
+
note: "works from any zone",
|
|
1094
|
+
rows: [
|
|
1095
|
+
["[ / ]", "Previous / next day (tasks + calendar events)"],
|
|
1096
|
+
["\\", "Jump back to today"],
|
|
1097
|
+
],
|
|
1098
|
+
},
|
|
1099
|
+
{
|
|
1100
|
+
emoji: "⏰",
|
|
1101
|
+
title: "Agenda — scheduling",
|
|
1102
|
+
rows: [
|
|
1103
|
+
["n / click slot", "New Google Calendar event (needs: calendar-setup google --write) append date+time: Lunch tomorrow 12-13 · Review 2026-06-10 15-16 · Holiday 25-12 allday"],
|
|
1104
|
+
["click an event", "Select an editable Google event — then e edit · d delete · Esc"],
|
|
1105
|
+
["c (any zone)", "Toggle ARM MODE — then click a task, click a slot, repeat"],
|
|
1106
|
+
["click empty row", "Place the armed task here (30-min block, or move if it has one)"],
|
|
1107
|
+
["click band", "Arm an existing block (or place the armed task at its start)"],
|
|
1108
|
+
["shift+click row", "While armed (existing block): resize end to that row"],
|
|
1109
|
+
["j / k", "While armed: nudge block ±15 min"],
|
|
1110
|
+
["+ / -", "While armed: resize block end ±15 min"],
|
|
1111
|
+
["Enter", "While armed: commit + jump to source task"],
|
|
1112
|
+
["Esc", "Disarm / exit arm mode"],
|
|
1113
|
+
],
|
|
1114
|
+
},
|
|
1115
|
+
{
|
|
1116
|
+
emoji: "📋",
|
|
1117
|
+
title: "Board-only actions",
|
|
1118
|
+
rows: [
|
|
1119
|
+
["n", "New task in current column (quick-add syntax)"],
|
|
1120
|
+
["g", "Grab task — h/l then moves it between columns; g/Esc to drop"],
|
|
1121
|
+
["f", "Cycle board filter: all → today → overdue → tomorrow → followup (Agents zone: harness filter)"],
|
|
1122
|
+
["/", "Search task titles — jumps cursor to first match"],
|
|
1123
|
+
],
|
|
1124
|
+
},
|
|
1125
|
+
{
|
|
1126
|
+
emoji: "🤖",
|
|
1127
|
+
title: "Agents zone",
|
|
1128
|
+
rows: [
|
|
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"],
|
|
1133
|
+
],
|
|
1134
|
+
},
|
|
1135
|
+
{
|
|
1136
|
+
emoji: "☑️",
|
|
1137
|
+
title: "Multi-select",
|
|
1138
|
+
rows: [
|
|
1139
|
+
["Space", "Mark / unmark task (cursor stays — mark in any order) Every task action (done/schedule/time block/assign/priority/archive/delete) then applies to ALL marked"],
|
|
1140
|
+
["Esc", "Clear the selection (when no modal is open)"],
|
|
1141
|
+
],
|
|
1142
|
+
},
|
|
1143
|
+
{
|
|
1144
|
+
emoji: "📦",
|
|
1145
|
+
title: "Bulk",
|
|
1146
|
+
rows: [["T", "Reset ALL overdue tasks (any board) to today"]],
|
|
1147
|
+
},
|
|
1148
|
+
{
|
|
1149
|
+
emoji: "🌐",
|
|
1150
|
+
title: "Global",
|
|
1151
|
+
rows: [
|
|
1152
|
+
["Ctrl-Z", "Undo last mutation"],
|
|
1153
|
+
["?", "This help"],
|
|
1154
|
+
["q · Ctrl-C", "Quit"],
|
|
1155
|
+
],
|
|
1156
|
+
},
|
|
1157
|
+
];
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* Flattened render+scroll units: one "header" block per section (carrying its
|
|
1161
|
+
* optional note) followed by one "row" block per shortcut. `ui.helpScroll` is
|
|
1162
|
+
* an index into this list; `j`/`k` step it and the matching `<box id>` is
|
|
1163
|
+
* scrolled into view.
|
|
1164
|
+
*/
|
|
1165
|
+
type HelpBlock =
|
|
1166
|
+
| { kind: "header"; emoji: string; title: string; note?: string }
|
|
1167
|
+
| { kind: "row"; keyLabel: string; desc: string };
|
|
1168
|
+
|
|
1169
|
+
const HELP_BLOCKS: HelpBlock[] = HELP_SECTIONS.flatMap((s) => [
|
|
1170
|
+
{ kind: "header", emoji: s.emoji, title: s.title, note: s.note } as HelpBlock,
|
|
1171
|
+
...s.rows.map(([keyLabel, desc]) => ({ kind: "row", keyLabel, desc }) as HelpBlock),
|
|
1172
|
+
]);
|
|
1173
|
+
|
|
1174
|
+
/** Dim dot leader that pads `key` out to the fixed key column (with one leading
|
|
1175
|
+
* separator space). Empty for keys that already fill the column. */
|
|
1176
|
+
function helpDots(key: string): string {
|
|
1177
|
+
const n = HELP_KEYW - cellWidth(key) - 1;
|
|
1178
|
+
return n > 0 ? "·".repeat(n) : "";
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/** Minimal structural view of OpenTUI's <scrollbox> ref (mirrors TimelineView). */
|
|
1182
|
+
interface ScrollBoxLike {
|
|
1183
|
+
scrollChildIntoView(id: string): void;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Keyboard reference.
|
|
1188
|
+
*
|
|
1189
|
+
* Each shortcut is a SINGLE full-width `<text wrapMode="word">` (key + dim dot
|
|
1190
|
+
* leader + description as spans). A single full-width text manages its own
|
|
1191
|
+
* wrapped height correctly; a wrapping `<text>` placed as a flex *sibling* in a
|
|
1192
|
+
* row does NOT (the next row overpaints it — the "bleed-into-neighbor" glitch
|
|
1193
|
+
* documented in TaskRow). When a description wraps it returns to column 0 in the
|
|
1194
|
+
* dim description color, visually distinct from the accent key.
|
|
1195
|
+
*
|
|
1196
|
+
* The whole list lives in a `<scrollbox>` so it never clips on short terminals;
|
|
1197
|
+
* `j`/`k` move `ui.helpScroll` and the focused block is scrolled into view.
|
|
1198
|
+
*/
|
|
1004
1199
|
function HelpModal(props: { store: TuiStore }) {
|
|
1005
|
-
|
|
1200
|
+
let scrollBoxRef: ScrollBoxLike | undefined;
|
|
1201
|
+
|
|
1202
|
+
createEffect(() => {
|
|
1203
|
+
const raw = props.store.state.ui.helpScroll;
|
|
1204
|
+
const clamped = Math.max(0, Math.min(raw, HELP_BLOCKS.length - 1));
|
|
1205
|
+
if (clamped !== raw) {
|
|
1206
|
+
props.store.setHelpScroll(clamped);
|
|
1207
|
+
return; // effect re-runs with the corrected value
|
|
1208
|
+
}
|
|
1209
|
+
scrollBoxRef?.scrollChildIntoView(`help-b${clamped}`);
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1006
1212
|
return (
|
|
1007
|
-
<DialogShell title="tuiboard — keyboard reference" hint="Esc/?
|
|
1008
|
-
<
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
<
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
<span style={{ fg: T.text }}>{" Space Mark / unmark task (cursor stays — mark in any order)\n"}</span>
|
|
1060
|
-
<span style={{ fg: T.text }}>{" Every task action (done/schedule/time block/assign/\n"}</span>
|
|
1061
|
-
<span style={{ fg: T.text }}>{" priority/archive/delete) then applies to ALL marked\n"}</span>
|
|
1062
|
-
<span style={{ fg: T.text }}>{" Esc Clear the selection (when no modal is open)\n"}</span>
|
|
1063
|
-
<span style={{ fg: T.textDim }}>{"\nBulk\n"}</span>
|
|
1064
|
-
<span style={{ fg: T.text }}>{" T Reset ALL overdue tasks (any board) to today\n"}</span>
|
|
1065
|
-
<span style={{ fg: T.textDim }}>{"\nGlobal\n"}</span>
|
|
1066
|
-
<span style={{ fg: T.text }}>{" Ctrl-Z Undo last mutation\n"}</span>
|
|
1067
|
-
<span style={{ fg: T.text }}>{" ? This help\n"}</span>
|
|
1068
|
-
<span style={{ fg: T.text }}>{" q · Ctrl-C Quit\n"}</span>
|
|
1069
|
-
</text>
|
|
1213
|
+
<DialogShell title="tuiboard — keyboard reference" hint="Esc/? close · j/k scroll" width={92}>
|
|
1214
|
+
<scrollbox
|
|
1215
|
+
ref={(r: ScrollBoxLike) => (scrollBoxRef = r)}
|
|
1216
|
+
style={{
|
|
1217
|
+
width: "100%",
|
|
1218
|
+
flexGrow: 1,
|
|
1219
|
+
scrollX: false,
|
|
1220
|
+
scrollY: true,
|
|
1221
|
+
rootOptions: {},
|
|
1222
|
+
contentOptions: {},
|
|
1223
|
+
scrollbarOptions: { visible: false },
|
|
1224
|
+
}}
|
|
1225
|
+
>
|
|
1226
|
+
<For each={HELP_BLOCKS}>
|
|
1227
|
+
{(block, i) => (
|
|
1228
|
+
<box id={`help-b${i()}`} style={{ flexDirection: "column" }}>
|
|
1229
|
+
<Show when={block.kind === "header"}>
|
|
1230
|
+
{/* blank line above every section header except the first */}
|
|
1231
|
+
<Show when={i() > 0}>
|
|
1232
|
+
<box style={{ height: 1 }} />
|
|
1233
|
+
</Show>
|
|
1234
|
+
<text wrapMode="none" truncate>
|
|
1235
|
+
<span style={{ fg: T.warm, attributes: ATTR.bold }}>
|
|
1236
|
+
{`${(block as { emoji: string }).emoji} ${(block as { title: string }).title}`}
|
|
1237
|
+
</span>
|
|
1238
|
+
</text>
|
|
1239
|
+
{/* scope note on its own wrapping line, kept verbatim */}
|
|
1240
|
+
<Show when={(block as { note?: string }).note}>
|
|
1241
|
+
<text wrapMode="word">
|
|
1242
|
+
<span style={{ fg: T.textDone, attributes: ATTR.italic }}>
|
|
1243
|
+
{(block as { note: string }).note}
|
|
1244
|
+
</span>
|
|
1245
|
+
</text>
|
|
1246
|
+
</Show>
|
|
1247
|
+
</Show>
|
|
1248
|
+
<Show when={block.kind === "row"}>
|
|
1249
|
+
<text wrapMode="word">
|
|
1250
|
+
<span style={{ fg: T.accent, attributes: ATTR.bold }}>
|
|
1251
|
+
{(block as { keyLabel: string }).keyLabel}
|
|
1252
|
+
</span>
|
|
1253
|
+
<span style={{ fg: T.border }}>
|
|
1254
|
+
{` ${helpDots((block as { keyLabel: string }).keyLabel)}`}
|
|
1255
|
+
</span>
|
|
1256
|
+
<span style={{ fg: T.textDim }}>
|
|
1257
|
+
{` ${(block as { desc: string }).desc}`}
|
|
1258
|
+
</span>
|
|
1259
|
+
</text>
|
|
1260
|
+
</Show>
|
|
1261
|
+
</box>
|
|
1262
|
+
)}
|
|
1263
|
+
</For>
|
|
1264
|
+
</scrollbox>
|
|
1070
1265
|
</DialogShell>
|
|
1071
1266
|
);
|
|
1072
1267
|
}
|
|
@@ -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());
|