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.
@@ -1,19 +1,35 @@
1
1
  /**
2
- * Single-line render of an AgentSession. Used in both AgentsBar (compact
3
- * dashboard strip) and AgentsOnly (fullscreen list).
2
+ * Render of an AgentSession, in two densities:
4
3
  *
5
- * Layout: cursor · status-dot · name · git branch ……right-pinned: cwd_short · age
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 { formatAge, type AgentSession, type AgentStatus } from "~/store/agents";
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-pid": T.bannerWarn,
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-pid": "△",
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
- <box
49
- style={{
50
- flexDirection: "row",
51
- paddingLeft: 1,
52
- paddingRight: 1,
53
- backgroundColor: props.cursor ? T.cardBgCursor : undefined,
54
- }}
55
- onMouseDown={props.onClick ? (() => props.onClick!()) : undefined}
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
- {/* `truncate` is on as a safety net — our own displayName tail
58
- truncation in AgentRow normally controls the visible string,
59
- but OpenTUI's clip-at-cell-bound prevents bleed into adjacent
60
- renderables in edge cases (terminal emoji width quirks etc.). */}
61
- <text style={{ flexGrow: 1, flexShrink: 1 }} wrapMode="none" truncate>
62
- <span style={{ fg: props.cursor ? T.accent : T.textDim }}>
63
- {props.cursor ? "▶ " : " "}
64
- </span>
65
- <span style={{ fg: STATUS_COLOR[props.session.status] }}>
66
- {STATUS_GLYPH[props.session.status]}{" "}
67
- </span>
68
- <span style={{ fg: T.text }}>{displayName()}</span>
69
- <Show when={props.session.gitBranch}>
70
- <span style={{ fg: T.textDim }}>{" "}{props.session.gitBranch}</span>
71
- </Show>
72
- </text>
73
- {/* cwd + age pinned together on the right. The age is right-aligned in a
74
- fixed-width field (pad to 3: "59m" / "23h" / "10d") so the END of each
75
- cwd lands on the same column across rows — a 1- vs 2-digit age no
76
- longer shoves the directory names out of vertical alignment. */}
77
- <text style={{ flexShrink: 0 }} wrapMode="none">
78
- <span style={{ fg: T.textDim }}>
79
- {props.session.cwdShort}{" "}{ageStr().padStart(3)}
80
- </span>
81
- </text>
82
- </box>
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
  }
@@ -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.agents.sessions().filter((s) => s.status !== "archived"),
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 WezTerm; c copies this command to paste anywhere:
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 WezTerm tab"],
1097
- ["c", "Copy a 'cd + claude --resume' command for the session"],
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
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Fullscreen list of every local Claude Code session.
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.agents.sessions());
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
- No sessions found in ~/.claude/projects.
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());