From 0bc94422466e1a8a7edfde0926e9b768cee828c0 Mon Sep 17 00:00:00 2001
From: dot-agi
Date: Tue, 20 Oct 2026 18:08:00 +0530
Subject: [PATCH 1/2] Release 1.1.3
---
.claude-plugin/plugin.json | 2 +-
README.md | 4 +-
docs/usage.md | 2 +
hooks/drawing.tsx | 9 ++-
hooks/register.tsx | 5 +-
hooks/view.ts | 153 +++++++++++++++++++++++++++++++++++++
6 files changed, 168 insertions(+), 7 deletions(-)
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index bca7ce4..4e5595b 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "downrange",
- "version": "1.1.2",
+ "version": "1.1.3",
"description": "A band, pane, and /downrange table: progress, ETA, and last output for Claude Code background shells and Monitor watches, tool activity for subagents, state for workflows, and the next /loop wakeup.",
"author": { "name": "dot-agi" },
"license": "MIT",
diff --git a/README.md b/README.md
index fba3a63..a7291c0 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
-
+
Progress and an ETA for background shells and Monitor watches, read from their real output or past runs, with no model request.
@@ -139,7 +139,7 @@ Notes:
| `c` in the focused pane | Clears finished jobs |
| `x` in the focused pane | Closes the pane |
-Focus the band or the pane with Ctrl+X, then Tab, or a click. That chord is Claude Code's own `abovePrompt:focus` binding. Release Ctrl before you press Tab, because many terminals read Ctrl+Tab as next tab. In Emacs vterm, eat, or ansi-term, click the band, use `/downrange pane`, or rebind `abovePrompt:focus` with [`/keybindings`](https://code.claude.com/docs/en/keybindings#above-prompt-actions).
+Focus the band or the pane with Ctrl+X, then Tab, or a click. That chord is Claude Code's own `abovePrompt:focus` binding. Release Ctrl before you press Tab, because many terminals read Ctrl+Tab as next tab. On the focused band, Tab moves between **Open** and **Hide**, Enter presses one, and Esc returns to the prompt. When the band's row has free room, it shows the dim hint `ctrl+x tab to choose` just before **Open**. In Emacs vterm, eat, or ansi-term, click the band, use `/downrange pane`, or rebind `abovePrompt:focus` with [`/keybindings`](https://code.claude.com/docs/en/keybindings#above-prompt-actions).
## Options
diff --git a/docs/usage.md b/docs/usage.md
index 4d5380e..e8cad9d 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -33,6 +33,8 @@ The band is one line above the prompt:
- Job snippets follow: recent failures, running jobs with progress, other running jobs (oldest first), then other recent ends. Snippets that do not fit the width are left out. The counts always show.
- **Open** (`j`) opens the pane. **Hide** (`h`) hides the band until a new job starts.
- Focus the band with Ctrl+X, then Tab, or a click before you use its keys. That chord is Claude Code's own `abovePrompt:focus` binding. Release Ctrl before you press Tab, because many terminals read Ctrl+Tab as next tab. In Emacs vterm, eat, or ansi-term, click the band, use `/downrange pane`, or rebind `abovePrompt:focus` with [`/keybindings`](https://code.claude.com/docs/en/keybindings#above-prompt-actions).
+- On the focused band, Tab moves between **Open** and **Hide**, Enter presses one, and Esc returns to the prompt.
+- When the row has free room after the counts and the snippets, the dim hint `ctrl+x tab to choose` shows just before **Open**. The hint never takes room from a snippet and never moves a button. A narrow or full band leaves it out.
- A survey takes the band's place while it shows.
- The option `band` turns the band off.
diff --git a/hooks/drawing.tsx b/hooks/drawing.tsx
index 293e36e..35d07fc 100644
--- a/hooks/drawing.tsx
+++ b/hooks/drawing.tsx
@@ -10,8 +10,8 @@ const PART_STYLES: Readonly void; hide: () => void }, below: RenderElement): RenderElement {
+/** The band: one line of counts and snippets, the key hint in the free room before Open and Hide when the row holds it, and what other mods drew beneath. */
+export function bandTree(ui: Ui, parts: readonly BandPart[], hint: string | undefined, actions: { open: () => void; hide: () => void }, below: RenderElement): RenderElement {
const { Box, Text, Button } = ui
return (
@@ -23,6 +23,11 @@ export function bandTree(ui: Ui, parts: readonly BandPart[], actions: { open: ()
))}
+ {hint === undefined ? null : (
+
+ {hint}
+
+ )}
diff --git a/hooks/register.tsx b/hooks/register.tsx
index 6b94584..64dd5e1 100644
--- a/hooks/register.tsx
+++ b/hooks/register.tsx
@@ -45,7 +45,7 @@ import { displayLinesOf, isOutputFileOf, jobBytesOf, jobLinesOf, oneLine, output
import { jobsStateOf, wakeupStateOf } from './state'
import { isStatusPrompt, normalizePrompt } from './prompt'
import { redirectFileOf, redirectTargetOf } from './redirect'
-import { bandOf, isBandHidden, paneModelOf, tableOf } from './view'
+import { bandHintOf, bandOf, isBandHidden, paneModelOf, tableOf } from './view'
/** The slash command, without its slash; declared here so that `claude plugin validate` resolves the matcher. */
const COMMAND = 'downrange'
@@ -715,7 +715,8 @@ export const register: Register = (on, options) => {
void pressed($, 'Hide')
},
}
- return bandTree($.ui.resolve(e), bandOf(jobs, wakeup, now, e.props.bodyColumns), actions, await next(e))
+ const parts = bandOf(jobs, wakeup, now, e.props.bodyColumns)
+ return bandTree($.ui.resolve(e), parts, bandHintOf(parts, e.props.bodyColumns), actions, await next(e))
}).catch(($, e, next) => passOn($, 'ui.render{component=AbovePrompt}', e, next))
on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
diff --git a/hooks/view.ts b/hooks/view.ts
index c96e77a..5af91f8 100644
--- a/hooks/view.ts
+++ b/hooks/view.ts
@@ -248,6 +248,159 @@ export function bandOf(jobs: readonly Job[], wakeup: Wakeup | null, now: number,
return [...joined(counts, ' '), ...divider, ...joined(snippets, ' ยท ')]
}
+/** The band's sign of its keyboard path, drawn dim in the free room right before its first Button. */
+const BAND_KEY_HINT = 'ctrl+x tab to choose'
+
+/** Code point ranges a terminal draws two cells wide: every East Asian Wide (W) and Fullwidth (F) range of Unicode 17.0.0. A terminal that follows an older Unicode can draw some of them one cell wide, so the count errs toward less room. */
+const WIDE_RANGES: readonly (readonly [number, number])[] = [
+ [0x1100, 0x115f],
+ [0x231a, 0x231b],
+ [0x2329, 0x232a],
+ [0x23e9, 0x23ec],
+ [0x23f0, 0x23f0],
+ [0x23f3, 0x23f3],
+ [0x25fd, 0x25fe],
+ [0x2614, 0x2615],
+ [0x2630, 0x2637],
+ [0x2648, 0x2653],
+ [0x267f, 0x267f],
+ [0x268a, 0x268f],
+ [0x2693, 0x2693],
+ [0x26a1, 0x26a1],
+ [0x26aa, 0x26ab],
+ [0x26bd, 0x26be],
+ [0x26c4, 0x26c5],
+ [0x26ce, 0x26ce],
+ [0x26d4, 0x26d4],
+ [0x26ea, 0x26ea],
+ [0x26f2, 0x26f3],
+ [0x26f5, 0x26f5],
+ [0x26fa, 0x26fa],
+ [0x26fd, 0x26fd],
+ [0x2705, 0x2705],
+ [0x270a, 0x270b],
+ [0x2728, 0x2728],
+ [0x274c, 0x274c],
+ [0x274e, 0x274e],
+ [0x2753, 0x2755],
+ [0x2757, 0x2757],
+ [0x2795, 0x2797],
+ [0x27b0, 0x27b0],
+ [0x27bf, 0x27bf],
+ [0x2b1b, 0x2b1c],
+ [0x2b50, 0x2b50],
+ [0x2b55, 0x2b55],
+ [0x2e80, 0x2e99],
+ [0x2e9b, 0x2ef3],
+ [0x2f00, 0x2fd5],
+ [0x2ff0, 0x303e],
+ [0x3041, 0x3096],
+ [0x3099, 0x30ff],
+ [0x3105, 0x312f],
+ [0x3131, 0x318e],
+ [0x3190, 0x31e5],
+ [0x31ef, 0x321e],
+ [0x3220, 0x3247],
+ [0x3250, 0xa48c],
+ [0xa490, 0xa4c6],
+ [0xa960, 0xa97c],
+ [0xac00, 0xd7a3],
+ [0xf900, 0xfaff],
+ [0xfe10, 0xfe19],
+ [0xfe30, 0xfe52],
+ [0xfe54, 0xfe66],
+ [0xfe68, 0xfe6b],
+ [0xff01, 0xff60],
+ [0xffe0, 0xffe6],
+ [0x16fe0, 0x16fe4],
+ [0x16ff0, 0x16ff6],
+ [0x17000, 0x18cd5],
+ [0x18cff, 0x18d1e],
+ [0x18d80, 0x18df2],
+ [0x1aff0, 0x1aff3],
+ [0x1aff5, 0x1affb],
+ [0x1affd, 0x1affe],
+ [0x1b000, 0x1b122],
+ [0x1b132, 0x1b132],
+ [0x1b150, 0x1b152],
+ [0x1b155, 0x1b155],
+ [0x1b164, 0x1b167],
+ [0x1b170, 0x1b2fb],
+ [0x1d300, 0x1d356],
+ [0x1d360, 0x1d376],
+ [0x1f004, 0x1f004],
+ [0x1f0cf, 0x1f0cf],
+ [0x1f18e, 0x1f18e],
+ [0x1f191, 0x1f19a],
+ [0x1f200, 0x1f202],
+ [0x1f210, 0x1f23b],
+ [0x1f240, 0x1f248],
+ [0x1f250, 0x1f251],
+ [0x1f260, 0x1f265],
+ [0x1f300, 0x1f320],
+ [0x1f32d, 0x1f335],
+ [0x1f337, 0x1f37c],
+ [0x1f37e, 0x1f393],
+ [0x1f3a0, 0x1f3ca],
+ [0x1f3cf, 0x1f3d3],
+ [0x1f3e0, 0x1f3f0],
+ [0x1f3f4, 0x1f3f4],
+ [0x1f3f8, 0x1f43e],
+ [0x1f440, 0x1f440],
+ [0x1f442, 0x1f4fc],
+ [0x1f4ff, 0x1f53d],
+ [0x1f54b, 0x1f54e],
+ [0x1f550, 0x1f567],
+ [0x1f57a, 0x1f57a],
+ [0x1f595, 0x1f596],
+ [0x1f5a4, 0x1f5a4],
+ [0x1f5fb, 0x1f64f],
+ [0x1f680, 0x1f6c5],
+ [0x1f6cc, 0x1f6cc],
+ [0x1f6d0, 0x1f6d2],
+ [0x1f6d5, 0x1f6d8],
+ [0x1f6dc, 0x1f6df],
+ [0x1f6eb, 0x1f6ec],
+ [0x1f6f4, 0x1f6fc],
+ [0x1f7e0, 0x1f7eb],
+ [0x1f7f0, 0x1f7f0],
+ [0x1f90c, 0x1f93a],
+ [0x1f93c, 0x1f945],
+ [0x1f947, 0x1f9ff],
+ [0x1fa70, 0x1fa7c],
+ [0x1fa80, 0x1fa8a],
+ [0x1fa8e, 0x1fac6],
+ [0x1fac8, 0x1fac8],
+ [0x1facd, 0x1fadc],
+ [0x1fadf, 0x1faea],
+ [0x1faef, 0x1faf8],
+ [0x20000, 0x2fffd],
+ [0x30000, 0x3fffd],
+]
+const EMOJI = /\p{Emoji_Presentation}/u
+const ZERO_WIDTH = /[\p{Mn}\p{Me}\p{Cc}\p{Cf}]/u
+/** U+FE0F asks for the emoji form of the character before it, two cells wide: it counts as that second cell. */
+const EMOJI_FORM = 0xfe0f
+
+/** The display cells of text: two for a wide or emoji character, none for a mark, a control, or a format character. An emoji sequence counts high, so a room check errs toward less. */
+export function cellsOf(text: string): number {
+ let cells = 0
+ for (const char of text) {
+ const code = char.codePointAt(0) ?? 0
+ if (code === EMOJI_FORM) cells += 1
+ else if (ZERO_WIDTH.test(char)) continue
+ else if (EMOJI.test(char) || WIDE_RANGES.some(([first, last]) => code >= first && code <= last)) cells += 2
+ else cells += 1
+ }
+ return cells
+}
+
+/** The key hint when the row holds it after the band line, before the Buttons, with a one-cell gap; the band line keeps all its room, in display cells. */
+export function bandHintOf(parts: readonly BandPart[], columns: number): string | undefined {
+ const line = parts.reduce((sum, part) => sum + cellsOf(part.text), 0)
+ return line + 1 + cellsOf(BAND_KEY_HINT) + BAND_BUTTONS_COLUMNS <= columns ? BAND_KEY_HINT : undefined
+}
+
/** Hidden: the user pressed Hide, and no job started after that. */
export function isBandHidden(jobs: readonly Job[], hiddenAt: number | null): boolean {
return hiddenAt !== null && jobs.every(job => job.startedAt <= hiddenAt)
From d0e8c196e58f55c353c76931ca0461ca089d3105 Mon Sep 17 00:00:00 2001
From: dot-agi
Date: Tue, 20 Oct 2026 19:37:25 +0530
Subject: [PATCH 2/2] Count band text cells as an upper bound
---
docs/usage.md | 2 +-
hooks/view.ts | 142 ++------------------------------------------------
2 files changed, 5 insertions(+), 139 deletions(-)
diff --git a/docs/usage.md b/docs/usage.md
index e8cad9d..62e0fac 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -34,7 +34,7 @@ The band is one line above the prompt:
- **Open** (`j`) opens the pane. **Hide** (`h`) hides the band until a new job starts.
- Focus the band with Ctrl+X, then Tab, or a click before you use its keys. That chord is Claude Code's own `abovePrompt:focus` binding. Release Ctrl before you press Tab, because many terminals read Ctrl+Tab as next tab. In Emacs vterm, eat, or ansi-term, click the band, use `/downrange pane`, or rebind `abovePrompt:focus` with [`/keybindings`](https://code.claude.com/docs/en/keybindings#above-prompt-actions).
- On the focused band, Tab moves between **Open** and **Hide**, Enter presses one, and Esc returns to the prompt.
-- When the row has free room after the counts and the snippets, the dim hint `ctrl+x tab to choose` shows just before **Open**. The hint never takes room from a snippet and never moves a button. A narrow or full band leaves it out.
+- When the row has free room after the counts and the snippets, the dim hint `ctrl+x tab to choose` shows just before **Open**. The hint never takes room from a snippet and never moves a button. A narrow or full band leaves it out. To decide, Downrange counts each character outside ASCII and its own band symbols as two cells, so a job title with accents, wide characters, or emoji can hide the hint a little earlier than needed.
- A survey takes the band's place while it shows.
- The option `band` turns the band off.
diff --git a/hooks/view.ts b/hooks/view.ts
index 5af91f8..d0a6c05 100644
--- a/hooks/view.ts
+++ b/hooks/view.ts
@@ -251,147 +251,13 @@ export function bandOf(jobs: readonly Job[], wakeup: Wakeup | null, now: number,
/** The band's sign of its keyboard path, drawn dim in the free room right before its first Button. */
const BAND_KEY_HINT = 'ctrl+x tab to choose'
-/** Code point ranges a terminal draws two cells wide: every East Asian Wide (W) and Fullwidth (F) range of Unicode 17.0.0. A terminal that follows an older Unicode can draw some of them one cell wide, so the count errs toward less room. */
-const WIDE_RANGES: readonly (readonly [number, number])[] = [
- [0x1100, 0x115f],
- [0x231a, 0x231b],
- [0x2329, 0x232a],
- [0x23e9, 0x23ec],
- [0x23f0, 0x23f0],
- [0x23f3, 0x23f3],
- [0x25fd, 0x25fe],
- [0x2614, 0x2615],
- [0x2630, 0x2637],
- [0x2648, 0x2653],
- [0x267f, 0x267f],
- [0x268a, 0x268f],
- [0x2693, 0x2693],
- [0x26a1, 0x26a1],
- [0x26aa, 0x26ab],
- [0x26bd, 0x26be],
- [0x26c4, 0x26c5],
- [0x26ce, 0x26ce],
- [0x26d4, 0x26d4],
- [0x26ea, 0x26ea],
- [0x26f2, 0x26f3],
- [0x26f5, 0x26f5],
- [0x26fa, 0x26fa],
- [0x26fd, 0x26fd],
- [0x2705, 0x2705],
- [0x270a, 0x270b],
- [0x2728, 0x2728],
- [0x274c, 0x274c],
- [0x274e, 0x274e],
- [0x2753, 0x2755],
- [0x2757, 0x2757],
- [0x2795, 0x2797],
- [0x27b0, 0x27b0],
- [0x27bf, 0x27bf],
- [0x2b1b, 0x2b1c],
- [0x2b50, 0x2b50],
- [0x2b55, 0x2b55],
- [0x2e80, 0x2e99],
- [0x2e9b, 0x2ef3],
- [0x2f00, 0x2fd5],
- [0x2ff0, 0x303e],
- [0x3041, 0x3096],
- [0x3099, 0x30ff],
- [0x3105, 0x312f],
- [0x3131, 0x318e],
- [0x3190, 0x31e5],
- [0x31ef, 0x321e],
- [0x3220, 0x3247],
- [0x3250, 0xa48c],
- [0xa490, 0xa4c6],
- [0xa960, 0xa97c],
- [0xac00, 0xd7a3],
- [0xf900, 0xfaff],
- [0xfe10, 0xfe19],
- [0xfe30, 0xfe52],
- [0xfe54, 0xfe66],
- [0xfe68, 0xfe6b],
- [0xff01, 0xff60],
- [0xffe0, 0xffe6],
- [0x16fe0, 0x16fe4],
- [0x16ff0, 0x16ff6],
- [0x17000, 0x18cd5],
- [0x18cff, 0x18d1e],
- [0x18d80, 0x18df2],
- [0x1aff0, 0x1aff3],
- [0x1aff5, 0x1affb],
- [0x1affd, 0x1affe],
- [0x1b000, 0x1b122],
- [0x1b132, 0x1b132],
- [0x1b150, 0x1b152],
- [0x1b155, 0x1b155],
- [0x1b164, 0x1b167],
- [0x1b170, 0x1b2fb],
- [0x1d300, 0x1d356],
- [0x1d360, 0x1d376],
- [0x1f004, 0x1f004],
- [0x1f0cf, 0x1f0cf],
- [0x1f18e, 0x1f18e],
- [0x1f191, 0x1f19a],
- [0x1f200, 0x1f202],
- [0x1f210, 0x1f23b],
- [0x1f240, 0x1f248],
- [0x1f250, 0x1f251],
- [0x1f260, 0x1f265],
- [0x1f300, 0x1f320],
- [0x1f32d, 0x1f335],
- [0x1f337, 0x1f37c],
- [0x1f37e, 0x1f393],
- [0x1f3a0, 0x1f3ca],
- [0x1f3cf, 0x1f3d3],
- [0x1f3e0, 0x1f3f0],
- [0x1f3f4, 0x1f3f4],
- [0x1f3f8, 0x1f43e],
- [0x1f440, 0x1f440],
- [0x1f442, 0x1f4fc],
- [0x1f4ff, 0x1f53d],
- [0x1f54b, 0x1f54e],
- [0x1f550, 0x1f567],
- [0x1f57a, 0x1f57a],
- [0x1f595, 0x1f596],
- [0x1f5a4, 0x1f5a4],
- [0x1f5fb, 0x1f64f],
- [0x1f680, 0x1f6c5],
- [0x1f6cc, 0x1f6cc],
- [0x1f6d0, 0x1f6d2],
- [0x1f6d5, 0x1f6d8],
- [0x1f6dc, 0x1f6df],
- [0x1f6eb, 0x1f6ec],
- [0x1f6f4, 0x1f6fc],
- [0x1f7e0, 0x1f7eb],
- [0x1f7f0, 0x1f7f0],
- [0x1f90c, 0x1f93a],
- [0x1f93c, 0x1f945],
- [0x1f947, 0x1f9ff],
- [0x1fa70, 0x1fa7c],
- [0x1fa80, 0x1fa8a],
- [0x1fa8e, 0x1fac6],
- [0x1fac8, 0x1fac8],
- [0x1facd, 0x1fadc],
- [0x1fadf, 0x1faea],
- [0x1faef, 0x1faf8],
- [0x20000, 0x2fffd],
- [0x30000, 0x3fffd],
-]
-const EMOJI = /\p{Emoji_Presentation}/u
-const ZERO_WIDTH = /[\p{Mn}\p{Me}\p{Cc}\p{Cf}]/u
-/** U+FE0F asks for the emoji form of the character before it, two cells wide: it counts as that second cell. */
-const EMOJI_FORM = 0xfe0f
+/** The characters that Downrange itself draws in the band: glyphs, separators, and bar parts, each one cell wide. */
+const BAND_GLYPHS: ReadonlySet = new Set([...'\u00b7\u2014\u2026\u2265\u2501\u2502\u2578\u257a\u2588\u2589\u258a\u258b\u258c\u258d\u258e\u258f\u25a0\u25b0\u25b1\u25cc\u25cf\u25f7\u2713\u2717'])
-/** The display cells of text: two for a wide or emoji character, none for a mark, a control, or a format character. An emoji sequence counts high, so a room check errs toward less. */
+/** An upper bound of the display cells of text: one for ASCII and for the band's own glyphs, two for any other character, also a combining, joining, or format character. A wide character and an emoji sequence count at least as wide as a terminal draws them, so a room check never admits text that is wider than it measured. */
export function cellsOf(text: string): number {
let cells = 0
- for (const char of text) {
- const code = char.codePointAt(0) ?? 0
- if (code === EMOJI_FORM) cells += 1
- else if (ZERO_WIDTH.test(char)) continue
- else if (EMOJI.test(char) || WIDE_RANGES.some(([first, last]) => code >= first && code <= last)) cells += 2
- else cells += 1
- }
+ for (const char of text) cells += (char.codePointAt(0) ?? 0) < 0x80 || BAND_GLYPHS.has(char) ? 1 : 2
return cells
}